diff --git a/application/single_app/config.py b/application/single_app/config.py
index bfc800eaf..9fd8886c8 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.306"
+VERSION = "0.261.307"
IS_DEVELOPMENT = is_development_env_enabled()
# Opt-out for deployments where App Service Easy Auth is active but the platform
diff --git a/application/v2_ui/src/components/approvals/PendingActionCard.tsx b/application/v2_ui/src/components/approvals/PendingActionCard.tsx
new file mode 100644
index 000000000..7f29aa510
--- /dev/null
+++ b/application/v2_ui/src/components/approvals/PendingActionCard.tsx
@@ -0,0 +1,270 @@
+// PendingActionCard.tsx
+// One saved Microsoft 365 email or calendar invitation that is waiting for its owner.
+//
+// The same card is drawn in two places: the Approvals detail ("detail") and inline in the chat
+// under the reply that saved it ("inline"). Both read the store of their nearest provider, so a
+// card follows the same rules wherever it is: Send and Cancel always carry the version that was
+// reviewed, the server's answer is authoritative, and nothing is ever sent automatically by the
+// browser.
+
+import { useEffect, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { ExternalLink, Loader2, RefreshCw, Send, X } from 'lucide-react';
+import {
+ formatDateTime,
+ pendingActionHeading,
+ pendingActionNeedsFullReview,
+ pendingActionSendRoute,
+ pendingActionStatusText,
+ textList,
+} from '../../lib/approvalsApi';
+import { chatHrefForPendingAction } from '../../lib/conversationUrl';
+import { M365_CHAT_CONNECTION_HREF, M365_CONNECT_HREF } from '../../lib/m365Links';
+import {
+ actionSubject,
+ countdownText,
+ isActionableStatus,
+ needsTicking,
+ safeApprovalDecisionHref,
+ safeWebLinkUrl,
+} from '../../lib/m365PendingActions';
+import { canChangeEntry, usePendingActions, usePendingActionsStoreApi } from '../../stores/m365PendingActionsStore';
+import { GlassButton } from '../ui/primitives';
+import { DetailShell, Fact, Facts, Notice, StatusBadge } from './ApprovalParts';
+
+export type PendingActionCardVariant = 'detail' | 'inline';
+
+const LINK_CLASS = 'inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-sm text-accent hover:bg-accent-soft';
+
+export function PendingActionCard({ id, variant = 'detail' }: { id: string; variant?: PendingActionCardVariant }) {
+ const store = usePendingActionsStoreApi();
+ const entry = usePendingActions((state) => state.entries[id]);
+ const [, setNow] = useState(0);
+ const ticking = entry ? needsTicking(entry.action) : false;
+ const inline = variant === 'inline';
+
+ // A scheduled or sending action repaints its countdown each second and asks the server again
+ // when its status is due; the store decides whether a request is actually needed.
+ useEffect(() => {
+ if (!ticking) return undefined;
+ const timer = window.setInterval(() => {
+ setNow(Date.now());
+ store.getState().pollIfDue(id);
+ }, 1000);
+ return () => window.clearInterval(timer);
+ }, [id, store, ticking]);
+
+ if (!entry) return null;
+
+ const { action, busy, notice, auth, approvalRequired, bodyOpen } = entry;
+ const loading = entry.refreshing;
+ const api = store.getState();
+ const summary = action.summary ?? {};
+ const fullReview = pendingActionNeedsFullReview(action);
+ const route = pendingActionSendRoute(action);
+ const actionable = isActionableStatus(action.status);
+ const canChange = canChangeEntry(entry);
+ const webLink = safeWebLinkUrl(action.web_link);
+ const approvalId = (() => {
+ const approvals = approvalRequired?.approvals;
+ const first = Array.isArray(approvals) ? (approvals[0] as { id?: unknown } | undefined)?.id : undefined;
+ const value = typeof first === 'string' ? first : approvalRequired?.approval_id;
+ return typeof value === 'string' ? value : '';
+ })();
+ const ownerOnlyNote =
+ actionable &&
+ !route &&
+ (action.can_cancel !== true || action.viewer_is_owner === false) &&
+ !action.requires_recreation &&
+ !fullReview;
+
+ const content = (
+ <>
+
+ {pendingActionStatusText(action)}
+
+
+ {textList(summary.to_recipients)}
+ {textList(summary.cc_recipients)}
+ {textList(summary.bcc_recipients)}
+ {textList(summary.attendee_recipients)}
+ {summary.start_datetime}
+ {summary.end_datetime}
+ {summary.timezone}
+ {summary.location}
+
+ {typeof summary.teams_meeting_requested === 'boolean' ? (summary.teams_meeting_requested ? 'Requested' : 'Not requested') : ''}
+
+
+
+ {typeof summary.body_preview === 'string' ? (
+ api.setBodyOpen(id, (event.target as HTMLDetailsElement).open)}
+ className="rounded-xl border border-edge p-3"
+ >
+
+ {fullReview ? 'Message preview' : 'Review message body'}
+
+
+ {summary.body_preview}
+
+ {String(summary.content_type ?? '').toLowerCase() === 'html' ? (
+ HTML content is displayed as text, not executed.
+ ) : null}
+
+ ) : null}
+
+ {fullReview ? (
+
+ {`This is a shortened preview${
+ Number.isSafeInteger(summary.body_length) && (summary.body_length ?? -1) >= 0
+ ? ` (${(summary.body_length as number).toLocaleString()} characters total)`
+ : ''
+ }. Review the complete saved content before sending.`}
+
+ ) : null}
+ {(actionable && (action.requires_recreation === true || action.requires_review === true)) || action.review_message ? (
+
+ {action.review_message ||
+ (action.requires_recreation
+ ? 'This legacy action cannot be safely sent. Review and recreate it if still needed, or cancel it.'
+ : 'Review the refreshed action details before sending.')}
+
+ ) : null}
+ {action.error ? {action.error} : null}
+ {action.delivery_note ? {action.delivery_note} : null}
+ {action.will_auto_send === true && actionable ? (
+
+
{`Server-scheduled delivery: ${formatDateTime(action.auto_send_at_utc)}.`}
+ {/* The countdown changes every second, so inline (inside the chat's live region) it stays out of the accessibility tree. */}
+
+ {countdownText(action)}
+
+
+ ) : null}
+ {notice ? (
+
+ {notice.text}
+
+ ) : null}
+ {ownerOnlyNote ? (
+
+ {action.workflow_id
+ ? 'Only the selected Run as user can send or cancel this action.'
+ : 'Only the action owner can send or cancel this action. This view is read-only.'}
+
+ ) : null}
+
+
+ {fullReview ? (
+
void api.reviewFull(id)}
+ >
+ {action.graph_resource_type === 'calendar' ? 'Review full invitation' : 'Review full message'}
+
+ ) : null}
+ {actionable && route ? (
+
void api.submit(id, route)}
+ data-testid="v2-pending-action-send"
+ >
+ {busy ? : }
+ {action.action_mode === 'delayed' ? 'Send now' : 'Send'}
+
+ ) : null}
+ {actionable && action.can_cancel === true && action.viewer_is_owner !== false ? (
+
void api.submit(id, 'cancel')}
+ data-testid="v2-pending-action-cancel"
+ >
+
+ Cancel
+
+ ) : null}
+ {approvalRequired && actionable ? (
+
+ Review sharing decision
+
+ ) : null}
+ {auth && action.viewer_is_owner !== false ? (
+ <>
+ {!action.workflow_id ? (
+
void api.reconnect(id)}>
+ Reconnect Microsoft 365
+
+ ) : null}
+
+ {action.workflow_id ? 'Reconnect workflow account in Settings' : 'Open Microsoft 365 settings'}
+
+ >
+ ) : null}
+
void api.refresh(id, { clearNotice: true })}
+ >
+ {loading ? : }
+ Refresh status
+
+ {webLink ? (
+
+
+ Open in Microsoft 365
+
+ ) : null}
+ {!inline && action.conversation_id ? (
+
+ Open conversation
+
+ ) : null}
+
+ >
+ );
+
+ if (!inline) {
+ return (
+ }
+ >
+ {content}
+
+ );
+ }
+
+ return (
+
+
+ {content}
+
+ );
+}
diff --git a/application/v2_ui/src/components/approvals/PendingActionsPanel.tsx b/application/v2_ui/src/components/approvals/PendingActionsPanel.tsx
index 066e473c9..15640ab79 100644
--- a/application/v2_ui/src/components/approvals/PendingActionsPanel.tsx
+++ b/application/v2_ui/src/components/approvals/PendingActionsPanel.tsx
@@ -1,79 +1,36 @@
// PendingActionsPanel.tsx
// Outgoing Microsoft 365 email and calendar invitations waiting to be sent.
//
-// Ports the classic m365-pending-actions card into the list/detail layout. Send and Cancel
-// always carry the version the user reviewed; the server rejects a stale version, and the
-// detail then shows the server's current state instead of retrying anything.
+// The list lives here; each action is drawn by the shared PendingActionCard, the same card the
+// chat shows under the reply that saved the action. Send and Cancel always carry the version the
+// user reviewed; the server rejects a stale version, and the card then shows the server's
+// current state instead of retrying anything.
import { useCallback, useEffect, useMemo, useState } from 'react';
-import { Link, useNavigate } from 'react-router-dom';
-import { ExternalLink, Loader2, RefreshCw, Send, X } from 'lucide-react';
-import { ApiError } from '../../lib/apiClient';
+import { useNavigate } from 'react-router-dom';
+import { Loader2 } from 'lucide-react';
import {
ACTIONABLE_PENDING_STATUSES,
- canChangePendingAction,
errorMessage,
- fetchPendingAction,
fetchPendingActions,
formatDateTime,
- isPendingAction,
- m365ErrorPayload,
- mutatePendingAction,
pendingActionHeading,
- pendingActionNeedsFullReview,
- pendingActionSendRoute,
- pendingActionStatusText,
textList,
type PendingAction,
} from '../../lib/approvalsApi';
-import { chatHrefForConversation } from '../../lib/conversationUrl';
-import { connectMicrosoft365, m365Sources } from '../../lib/m365Connect';
-import { M365_CHAT_CONNECTION_HREF, M365_CONNECT_HREF } from '../../lib/m365Links';
+import { actionSubject, normalizePendingActionId } from '../../lib/m365PendingActions';
+import { createPendingActionsStore, PendingActionsProvider, usePendingActions } from '../../stores/m365PendingActionsStore';
import { GlassButton, Skeleton } from '../ui/primitives';
import {
ApprovalSplit,
DetailEmpty,
- DetailShell,
- Fact,
- Facts,
ListMessage,
ListRow,
ListToolbar,
Notice,
StatusBadge,
} from './ApprovalParts';
-
-type Tone = 'info' | 'warn' | 'danger' | 'ok';
-
-function safeWebLinkUrl(value: unknown): string {
- if (typeof value !== 'string' || !value) return '';
- try {
- const url = new URL(value);
- return url.protocol === 'https:' && !url.username && !url.password ? url.href : '';
- } catch {
- return '';
- }
-}
-
-function safeApprovalDecisionHref(value: unknown): string {
- return typeof value === 'string' && value
- ? `/approvals/m365/${encodeURIComponent(value)}`
- : '/approvals/m365';
-}
-
-function countdownText(action: PendingAction): string {
- const due = Date.parse(action.auto_send_at_utc ?? '');
- if (!Number.isFinite(due)) return 'The scheduled time is unavailable. Refresh status to check it.';
- const seconds = Math.max(0, Math.ceil((due - Date.now()) / 1000));
- return seconds
- ? `Scheduled in ${Math.floor(seconds / 60)}m ${seconds % 60}s.`
- : 'Scheduled time reached. Waiting for the server delivery status.';
-}
-
-function actionSubject(action: PendingAction): string {
- return action.subject || action.summary?.subject || '(No subject)';
-}
-
+import { PendingActionCard } from './PendingActionCard';
export function PendingActionsPanel({
selectedId,
reloadKey,
@@ -219,351 +176,52 @@ function PendingActionDetail({
initial?: PendingAction;
onUpdated: (action: PendingAction) => void;
}) {
- const [action, setAction] = useState(initial ?? null);
- const [loading, setLoading] = useState(!initial);
- const [busy, setBusy] = useState(false);
- const [notice, setNotice] = useState<{ text: string; tone: Tone } | null>(null);
- const [needsRefresh, setNeedsRefresh] = useState(false);
- const [denied, setDenied] = useState(false);
- const [authRequired, setAuthRequired] = useState | null>(null);
- const [approvalRequired, setApprovalRequired] = useState | null>(null);
- const [bodyOpen, setBodyOpen] = useState(false);
- const [, setNow] = useState(0);
-
- const remember = useCallback(
- (next: PendingAction) => {
- setAction(next);
- onUpdated(next);
- },
- [onUpdated],
- );
+ const safeId = normalizePendingActionId(actionId);
+ // The detail shows one action, so it keeps a store of its own instead of the chat's.
+ const [store] = useState(() => createPendingActionsStore({ scope: 'detail', onUpdated }));
- const refresh = useCallback(
- async (options: { clearNotice?: boolean; signal?: AbortSignal } = {}): Promise => {
- setLoading(true);
- try {
- const next = await fetchPendingAction(actionId, options.signal);
- remember(next);
- setNeedsRefresh(false);
- setDenied(false);
- if (options.clearNotice) {
- setNotice({ text: 'Current server status loaded. Review the saved action before choosing Send or Cancel.', tone: 'info' });
- }
- return next;
- } catch (error) {
- if (options.signal?.aborted) return null;
- const status = error instanceof ApiError ? error.status : 0;
- setNeedsRefresh(true);
- setDenied(status === 403 || status === 404);
- setNotice({
- text:
- status === 403
- ? 'You do not have permission to access this action. No action was sent.'
- : 'The current action status could not be checked. Refresh status before trying again.',
- tone: 'warn',
- });
- if (status === 401 || m365ErrorPayload(error).auth_required === true) {
- setAuthRequired(m365ErrorPayload(error));
- }
- return null;
- } finally {
- if (!options.signal?.aborted) setLoading(false);
- }
- },
- [actionId, remember],
- );
-
- useEffect(() => {
- const controller = new AbortController();
- void refresh({ signal: controller.signal });
- return () => controller.abort();
- }, [refresh]);
-
- const scheduled =
- action?.will_auto_send === true &&
- ACTIONABLE_PENDING_STATUSES.has(action.status) &&
- Number.isFinite(Date.parse(action.auto_send_at_utc ?? ''));
useEffect(() => {
- if (!scheduled && action?.status !== 'sending') return undefined;
- const timer = window.setInterval(() => setNow(Date.now()), 1000);
- return () => window.clearInterval(timer);
- }, [scheduled, action?.status]);
-
- if (!action) {
- return loading ? (
-
-
-
-
- ) : (
+ if (!safeId) return undefined;
+ const api = store.getState();
+ // The list row is shown while the server's current copy loads; the copy is what can be sent.
+ if (initial && initial.id === safeId) api.remember(initial);
+ void api.loadById(safeId);
+ return () => store.getState().reset();
+ // The seed is only the first paint; a list update must not restart the load.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [safeId, store]);
+
+ if (!safeId) {
+ return (
- {notice?.text ?? 'The outgoing action could not be loaded.'}
+ The outgoing action could not be loaded.
);
}
- const summary = action.summary ?? {};
- const fullReview = pendingActionNeedsFullReview(action);
- const route = pendingActionSendRoute(action);
- const actionable = ACTIONABLE_PENDING_STATUSES.has(action.status);
- const canChange = !busy && !loading && !needsRefresh && !denied && canChangePendingAction(action);
- const webLink = safeWebLinkUrl(action.web_link);
- const approvalId = (() => {
- const approvals = approvalRequired?.approvals;
- const first = Array.isArray(approvals) ? (approvals[0] as { id?: unknown } | undefined)?.id : undefined;
- const id = typeof first === 'string' ? first : approvalRequired?.approval_id;
- return typeof id === 'string' ? id : '';
- })();
-
- const reviewFull = async () => {
- setNotice({ text: 'Loading the complete saved content for review. This review does not send the action.', tone: 'info' });
- const next = await refresh();
- if (!next) return;
- if (!pendingActionNeedsFullReview(next) && typeof next.summary?.body_preview === 'string') {
- setBodyOpen(true);
- setNotice({ text: 'Complete saved content loaded. Review the recipients and full content before sending.', tone: 'info' });
- } else {
- setNeedsRefresh(true);
- setNotice({ text: 'The complete saved content could not be verified. Review full details again before sending.', tone: 'warn' });
- }
- };
-
- const submit = async (operation: 'send-now' | 'approve' | 'cancel') => {
- const allowed = operation === 'cancel' ? action.can_cancel === true : Boolean(route) && !authRequired && !approvalRequired;
- if (!canChange || !allowed || !action.version) return;
- setBusy(true);
- setNotice({ text: operation === 'cancel' ? 'Cancelling this saved action…' : 'Submitting this saved action…', tone: 'info' });
- try {
- const next = await mutatePendingAction(action.id, operation, action.version);
- remember(next);
- setAuthRequired(null);
- setDenied(false);
- setNotice({
- text:
- operation === 'cancel'
- ? 'Cancellation checked. The server status below is authoritative; this does not recall an already sent item.'
- : 'Send request checked. Review the server status and delivery note below.',
- tone: 'info',
- });
- } catch (error) {
- const payload = m365ErrorPayload(error);
- const status = error instanceof ApiError ? error.status : 0;
- if (isPendingAction(payload.pending_action) && payload.pending_action.id === action.id) {
- remember(payload.pending_action);
- }
- if (payload.approval_required === true) {
- setApprovalRequired(payload);
- setNotice({
- text: 'Review the saved Microsoft 365 sharing decision before sending. Approving it does not send this action; return here and select Send again.',
- tone: 'warn',
- });
- } else if (status === 401 || payload.auth_required === true) {
- setAuthRequired(payload);
- setNotice({
- text: 'Microsoft 365 sign-in is required. Reconnect, review this same saved action, then select Send again. Signing in does not send it.',
- tone: 'warn',
- });
- } else if (status === 403) {
- setDenied(true);
- setNotice({ text: 'You do not have permission to change this action. Refresh status to check your current access.', tone: 'warn' });
- } else if (status === 409) {
- setNotice({
- text: 'This action changed or is already being processed. Review its current status and details before making another choice.',
- tone: 'warn',
- });
- if (!isPendingAction(payload.pending_action)) await refresh();
- } else {
- const refreshed = await refresh();
- setNotice({
- text: refreshed
- ? 'The response was not confirmed. Current server status has been refreshed. Review it before making another choice; no send was retried automatically.'
- : 'The response was not confirmed. Checking the current server status; the send will not be retried automatically.',
- tone: 'warn',
- });
- }
- } finally {
- setBusy(false);
- }
- };
-
- const reconnect = async () => {
- if (busy) return;
- setBusy(true);
- const reviewedVersion = action.version;
- setNotice({ text: 'Opening sign-in for this saved action. No chat request will be resumed and nothing will be sent.', tone: 'info' });
- try {
- const fromPayload = m365Sources(authRequired?.sources);
- await connectMicrosoft365(fromPayload.length ? fromPayload : [action.graph_resource_type === 'calendar' ? 'calendar' : 'email']);
- const next = await refresh({ clearNotice: true });
- if (next && next.version === reviewedVersion) setAuthRequired(null);
- } catch (error) {
- setNotice({ text: errorMessage(error, 'Sign-in was not confirmed. No action was sent.'), tone: 'warn' });
- } finally {
- setBusy(false);
- }
- };
-
- const ownerOnlyNote =
- actionable &&
- !route &&
- (action.can_cancel !== true || action.viewer_is_owner === false) &&
- !action.requires_recreation &&
- !fullReview;
-
return (
- }
- >
-
- {pendingActionStatusText(action)}
-
-
- {textList(summary.to_recipients)}
- {textList(summary.cc_recipients)}
- {textList(summary.bcc_recipients)}
- {textList(summary.attendee_recipients)}
- {summary.start_datetime}
- {summary.end_datetime}
- {summary.timezone}
- {summary.location}
-
- {typeof summary.teams_meeting_requested === 'boolean' ? (summary.teams_meeting_requested ? 'Requested' : 'Not requested') : ''}
-
-
-
- {typeof summary.body_preview === 'string' ? (
- setBodyOpen((event.target as HTMLDetailsElement).open)}
- className="rounded-xl border border-edge p-3"
- >
-
- {fullReview ? 'Message preview' : 'Review message body'}
-
-
- {summary.body_preview}
-
- {String(summary.content_type ?? '').toLowerCase() === 'html' ? (
- HTML content is displayed as text, not executed.
- ) : null}
-
- ) : null}
+
+
+
+ );
+}
- {fullReview ? (
-
- {`This is a shortened preview${
- Number.isSafeInteger(summary.body_length) && (summary.body_length ?? -1) >= 0
- ? ` (${(summary.body_length as number).toLocaleString()} characters total)`
- : ''
- }. Review the complete saved content before sending.`}
-
- ) : null}
- {(actionable && (action.requires_recreation === true || action.requires_review === true)) || action.review_message ? (
-
- {action.review_message ||
- (action.requires_recreation
- ? 'This legacy action cannot be safely sent. Review and recreate it if still needed, or cancel it.'
- : 'Review the refreshed action details before sending.')}
-
- ) : null}
- {action.error ? {action.error} : null}
- {action.delivery_note ? {action.delivery_note} : null}
- {action.will_auto_send === true && actionable ? (
-
-
{`Server-scheduled delivery: ${formatDateTime(action.auto_send_at_utc)}.`}
-
{countdownText(action)}
-
- ) : null}
- {notice ? {notice.text} : null}
- {ownerOnlyNote ? (
-
- {action.workflow_id
- ? 'Only the selected Run as user can send or cancel this action.'
- : 'Only the action owner can send or cancel this action. This view is read-only.'}
-
- ) : null}
+function PendingActionDetailBody({ actionId }: { actionId: string }) {
+ const present = usePendingActions((state) => Boolean(state.entries[actionId]));
+ const load = usePendingActions((state) => state.detail[actionId]);
-
- {fullReview ? (
-
void reviewFull()}>
- {action.graph_resource_type === 'calendar' ? 'Review full invitation' : 'Review full message'}
-
- ) : null}
- {actionable && route ? (
-
void submit(route)}
- data-testid="v2-pending-action-send"
- >
- {busy ? : }
- {action.action_mode === 'delayed' ? 'Send now' : 'Send'}
-
- ) : null}
- {actionable && action.can_cancel === true && action.viewer_is_owner !== false ? (
-
void submit('cancel')}
- data-testid="v2-pending-action-cancel"
- >
-
- Cancel
-
- ) : null}
- {approvalRequired && actionable ? (
-
- Review sharing decision
-
- ) : null}
- {authRequired && action.viewer_is_owner !== false ? (
- <>
- {!action.workflow_id ? (
-
void reconnect()}>
- Reconnect Microsoft 365
-
- ) : null}
-
- {action.workflow_id ? 'Reconnect workflow account in Settings' : 'Open Microsoft 365 settings'}
-
- >
- ) : null}
-
void refresh({ clearNotice: true })}>
- {loading ? : }
- Refresh status
-
- {webLink ? (
-
-
- Open in Microsoft 365
-
- ) : null}
- {action.conversation_id ? (
-
- Open conversation
-
- ) : null}
+ if (present) return
;
+ if (load?.status === 'error') {
+ return (
+
+ {load.message}
-
+ );
+ }
+ return (
+
+
+
+
);
}
diff --git a/application/v2_ui/src/components/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx
index 5af96e0ea..cc5aa0c7a 100644
--- a/application/v2_ui/src/components/chat/MessageList.tsx
+++ b/application/v2_ui/src/components/chat/MessageList.tsx
@@ -31,6 +31,12 @@ import { stripLegacyMapBlocks } from '../../lib/inlineMaps';
import { EmptyState, GlassButton, GlassPanel, Skeleton } from '../ui/primitives';
import { AssistantMarkdown } from './AssistantMarkdown';
import { M365CitationProvider } from './M365CitationContext';
+import {
+ InlinePendingActions,
+ PendingActionPlacementProvider,
+ PendingActionsConversationSection,
+ StreamingPendingActions,
+} from './PendingActionSlots';
import { InlineMapCards } from './InlineMapCard';
import { ChatFilePreview } from './ChatFilePreview';
import { GeneratedArtifactCard } from './GeneratedArtifactCard';
@@ -1227,6 +1233,8 @@ function MessageBubbleInner({
+
+
{maskingAllowed && !masks.fullyMasked && (
- {threadMessages.map((message) => (
-
- ))}
- {streaming && }
- {activeConversationId && (
-
- )}
-
-
-
+
+
+
+ {threadMessages.map((message) => (
+
+ ))}
+ {streaming &&
}
+
+ {activeConversationId && (
+
+ )}
+
+
+
+
{streamError && (
;
+
+const NO_PLACEMENT: Placement = new Map();
+const NO_IDS: readonly string[] = [];
+// These messages return before drawing a bubble body, so nothing can hang beneath them.
+const UNANCHORABLE_ROLES: ReadonlySet = new Set(['image', 'file', 'safety']);
+const FOCUS_HIGHLIGHT_MS = 2500;
+const UNAVAILABLE_MESSAGE =
+ 'That Microsoft 365 action is not available in this conversation. It may have been removed, or you may not have access to it.';
+
+const PlacementContext = createContext(NO_PLACEMENT);
+
+function usePlacementIds(anchor: string): readonly string[] {
+ return useContext(PlacementContext).get(anchor) ?? NO_IDS;
+}
+
+/**
+ * Decide, for every card the store holds, which message it hangs under.
+ *
+ * Wrap the thread in this. It renders nothing itself; the placement it computes is read by
+ * `InlinePendingActions` under each bubble and by the conversation-level section.
+ */
+export function PendingActionPlacementProvider({
+ messages,
+ children,
+}: {
+ messages: readonly ChatMessage[];
+ children?: ReactNode;
+}) {
+ const entries = usePendingActions((state) => state.entries);
+ const liveStream = usePendingActions((state) => state.liveStream);
+ const conversationId = usePendingActions((state) => state.conversationId);
+
+ const computed = useMemo(() => {
+ const anchorable = messages.filter((message) => !UNANCHORABLE_ROLES.has(message.role));
+ const context: AnchorContext = {
+ visibleMessageIds: new Set(anchorable.map((message) => message.id)),
+ lastMessageByRequestId: lastMessageByRequestId(anchorable),
+ streamingUserMessageId:
+ liveStream && liveStream.conversationId === conversationId ? liveStream.userMessageId : '',
+ };
+ const byAnchor = new Map();
+ for (const entry of Object.values(entries).sort((a, b) => a.order - b.order)) {
+ const anchor = resolveAnchor(entry.reference, entry.action, context);
+ const ids = byAnchor.get(anchor);
+ if (ids) {
+ ids.push(entry.id);
+ } else {
+ byAnchor.set(anchor, [entry.id]);
+ }
+ }
+ return { byAnchor, signature: JSON.stringify(Array.from(byAnchor)) };
+ }, [entries, liveStream, conversationId, messages]);
+
+ // The entries object changes whenever any card ticks, but placement only changes when a card
+ // moves. Keying the context value on the placement itself keeps a ticking countdown from
+ // re-rendering every slot in the thread.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const placement = useMemo(() => computed.byAnchor, [computed.signature]);
+
+ return {children} ;
+}
+
+/** The cards that belong under one message, or under the streaming reply. */
+export function InlinePendingActions({ anchor, className }: { anchor: string; className?: string }) {
+ const ids = usePlacementIds(anchor);
+ const chatWidth = useUiStore((state) => state.chatWidth);
+ if (!ids.length) {
+ return null;
+ }
+ return (
+
+ {ids.map((id) => (
+
+ ))}
+
+ );
+}
+
+/** Cards saved by a reply that is still streaming, drawn under the streaming bubble. */
+export function StreamingPendingActions() {
+ const ids = usePlacementIds(STREAMING_ANCHOR);
+ if (!ids.length) {
+ return null;
+ }
+ return (
+
+
+
+ );
+}
+
+/** Read the conversation's saved actions when it opens, and again when the person returns to it. */
+function usePendingActionsLifecycle(conversationId: string | null) {
+ const api = usePendingActionsStoreApi();
+ useEffect(() => {
+ if (!conversationId) {
+ return undefined;
+ }
+ const load = () => {
+ void api.getState().loadList();
+ };
+ load();
+ window.addEventListener('focus', load);
+ window.addEventListener('online', load);
+ return () => {
+ window.removeEventListener('focus', load);
+ window.removeEventListener('online', load);
+ };
+ }, [api, conversationId]);
+}
+
+interface Highlight {
+ node: HTMLElement;
+ timer: number;
+}
+
+function releaseHighlight(ref: { current: Highlight | null }) {
+ const current = ref.current;
+ if (!current) {
+ return;
+ }
+ window.clearTimeout(current.timer);
+ delete current.node.dataset.highlighted;
+ ref.current = null;
+}
+
+/**
+ * Bring a notification's card into view.
+ *
+ * The request waits for what it needs: the conversation's messages, the list of saved actions,
+ * and the card to be drawn. Once the card is on screen it is scrolled to, highlighted for a
+ * moment and focused, and the request is spent. If the card is not in the list it is fetched by
+ * id; only when that also fails does the person hear that it is unavailable.
+ */
+function usePendingActionFocus(scrollRef: RefObject, pinnedRef: { current: boolean }) {
+ const api = usePendingActionsStoreApi();
+ const request = usePendingActions((state) => state.focusRequest);
+ const conversationId = usePendingActions((state) => state.conversationId);
+ const listStatus = usePendingActions((state) => state.listStatus);
+ const referenceLoading = usePendingActions((state) => state.referenceLoading);
+ const present = usePendingActions((state) => Boolean(state.focusRequest && state.entries[state.focusRequest.id]));
+ const messagesLoading = useChatStore((state) => state.messagesLoading);
+ // Read so the search for the card runs again after cards have been drawn into their slots.
+ const placement = useContext(PlacementContext);
+ const [unavailableFor, setUnavailableFor] = useState('');
+ const lookedUp = useRef(0);
+ const highlighted = useRef(null);
+
+ useEffect(() => {
+ if (!request || request.conversationId !== conversationId || messagesLoading) {
+ return;
+ }
+ const root = scrollRef.current;
+ if (!root) {
+ return;
+ }
+ // Compared as data rather than interpolated into a selector, so no id can break out of one.
+ const node = Array.from(root.querySelectorAll('[data-pending-action-id]')).find(
+ (candidate) => candidate.dataset.pendingActionId === request.id,
+ );
+
+ if (node) {
+ // Leave auto-scroll alone: it would otherwise pull the thread back to the bottom.
+ pinnedRef.current = false;
+ const rootBox = root.getBoundingClientRect();
+ const nodeBox = node.getBoundingClientRect();
+ const margin = Math.max(16, (root.clientHeight - nodeBox.height) / 2);
+ const top = root.scrollTop + (nodeBox.top - rootBox.top) - margin;
+ const reduced =
+ typeof window.matchMedia === 'function' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+ root.scrollTo({ top: Math.max(0, top), behavior: reduced ? 'auto' : 'smooth' });
+ releaseHighlight(highlighted);
+ node.dataset.highlighted = 'true';
+ highlighted.current = {
+ node,
+ timer: window.setTimeout(() => releaseHighlight(highlighted), FOCUS_HIGHLIGHT_MS),
+ };
+ node.focus({ preventScroll: true });
+ api.getState().clearFocus();
+ return;
+ }
+
+ // The card is in the store but not drawn yet, or the lists it could appear in are still loading.
+ if (present || listStatus === 'idle' || listStatus === 'loading' || referenceLoading) {
+ return;
+ }
+ if (lookedUp.current === request.key) {
+ return;
+ }
+ lookedUp.current = request.key;
+ const { id, key } = request;
+ void api
+ .getState()
+ .loadById(id)
+ .then((action) => {
+ const state = api.getState();
+ if (action || state.focusRequest?.key !== key) {
+ return;
+ }
+ setUnavailableFor(state.conversationId);
+ state.clearFocus();
+ });
+ }, [api, conversationId, listStatus, messagesLoading, pinnedRef, placement, present, referenceLoading, request, scrollRef]);
+
+ // Only on unmount: clearing the focus request re-runs the effect above, which must not
+ // cancel the highlight it just applied.
+ useEffect(() => () => releaseHighlight(highlighted), []);
+
+ return {
+ unavailable: unavailableFor !== '' && unavailableFor === conversationId,
+ dismissUnavailable: () => setUnavailableFor(''),
+ };
+}
+
+/**
+ * The part of the thread that belongs to the conversation rather than to one message: saved
+ * actions whose message is not on screen, a failed or partial read of the list, and the notice
+ * for a deep link whose card cannot be shown. It also owns loading the list and the deep-link
+ * focus, since both depend on the cards having been drawn.
+ */
+export function PendingActionsConversationSection({
+ scrollRef,
+ pinnedRef,
+}: {
+ scrollRef: RefObject;
+ pinnedRef: { current: boolean };
+}) {
+ const api = usePendingActionsStoreApi();
+ const headingId = useId();
+ const activeConversationId = useChatStore((state) => state.activeConversationId);
+ const messagesLoading = useChatStore((state) => state.messagesLoading);
+ const orphanIds = usePlacementIds(ORPHAN_ANCHOR);
+ const listError = usePendingActions((state) => state.listError);
+ const referenceError = usePendingActions((state) => state.referenceError);
+ const continuationToken = usePendingActions((state) => state.continuationToken);
+ const loadingMore = usePendingActions((state) => state.loadingMore);
+ const listLoading = usePendingActions((state) => state.listStatus === 'loading');
+
+ usePendingActionsLifecycle(activeConversationId);
+ const { unavailable, dismissUnavailable } = usePendingActionFocus(scrollRef, pinnedRef);
+
+ // Held back while history loads, so a card is not shown here for an instant and then jump
+ // under its message.
+ const showSection =
+ !messagesLoading &&
+ (orphanIds.length > 0 || Boolean(listError) || Boolean(referenceError) || Boolean(continuationToken));
+ if (!unavailable && !showSection) {
+ return null;
+ }
+
+ return (
+ <>
+ {unavailable && (
+
+
{UNAVAILABLE_MESSAGE}
+
+ Dismiss
+
+
+ )}
+
+ {showSection && (
+
+
+
+ Microsoft 365 outgoing actions for this conversation
+
+
+ Saved actions without a visible originating message appear here. Opening a card never
+ sends it.
+
+
+
+ {listError && {listError} }
+ {referenceError && (
+ {referenceError}
+ )}
+
+ {orphanIds.length > 0 && (
+
+ {orphanIds.map((id) => (
+
+ ))}
+
+ )}
+
+
+ {continuationToken && (
+ void api.getState().loadList({ more: true })}
+ data-testid="v2-pending-actions-more"
+ >
+ {loadingMore && }
+ Load more outgoing actions
+
+ )}
+ void api.getState().refreshList()}
+ data-testid="v2-pending-actions-refresh"
+ >
+
+ Refresh outgoing actions
+
+
+
+ )}
+ >
+ );
+}
diff --git a/application/v2_ui/src/lib/approvalsApi.ts b/application/v2_ui/src/lib/approvalsApi.ts
index 695ddc1c0..6946e34fd 100644
--- a/application/v2_ui/src/lib/approvalsApi.ts
+++ b/application/v2_ui/src/lib/approvalsApi.ts
@@ -516,8 +516,21 @@ export interface PendingAction {
web_link?: string;
conversation_id?: string;
workflow_id?: string;
+ run_id?: string;
+ request_id?: string;
+ message_id?: string;
+ event_id?: string;
+ error_code?: string;
+ delay_seconds?: number;
+ /** Set when the owner's Microsoft 365 sign-in has to be renewed before this action can be sent. */
+ auth_required?: boolean;
+ sources?: unknown;
+ scopes?: unknown;
updated_at?: string;
created_at?: string;
+ completed_at?: string;
+ cancelled_at?: string;
+ failed_at?: string;
}
export const ACTIONABLE_PENDING_STATUSES = new Set(['pending', 'scheduled', 'review_required']);
@@ -592,6 +605,33 @@ export async function fetchPendingActions(
};
}
+/**
+ * One page of the outgoing actions saved in a conversation, for the chat view.
+ *
+ * Unlike the Approvals inbox this includes finished actions, so a reply that saved an email
+ * still shows what became of it. A page with a row that is not a saved action is refused
+ * rather than filtered, because a partly read page must never look like a complete one.
+ */
+export async function fetchConversationPendingActions(
+ conversationId: string,
+ continuationToken = '',
+ signal?: AbortSignal,
+): Promise<{ items: PendingAction[]; continuationToken: string }> {
+ const params = new URLSearchParams({ conversation_id: conversationId, limit: '30' });
+ if (continuationToken) params.set('continuation_token', continuationToken);
+ const data = await m365Request<{ pending_actions?: unknown; continuation_token?: unknown }>(
+ `/api/msgraph/pending-actions?${params.toString()}`,
+ { signal },
+ );
+ if (!Array.isArray(data?.pending_actions) || !data.pending_actions.every(isPendingAction)) {
+ throw new Error('Outgoing actions could not be verified.');
+ }
+ return {
+ items: data.pending_actions,
+ continuationToken: typeof data.continuation_token === 'string' ? data.continuation_token : '',
+ };
+}
+
function actionFromResponse(payload: unknown, id: string): PendingAction {
const action = (payload as { pending_action?: unknown } | null)?.pending_action;
if (!isPendingAction(action) || action.id !== id) {
@@ -600,9 +640,20 @@ function actionFromResponse(payload: unknown, id: string): PendingAction {
return action;
}
-export async function fetchPendingAction(id: string, signal?: AbortSignal): Promise {
+/**
+ * The current state of one saved action.
+ *
+ * Pass the conversation to read an action someone else saved in a shared conversation; the
+ * server then checks access to that conversation instead of ownership of the action.
+ */
+export async function fetchPendingAction(
+ id: string,
+ signal?: AbortSignal,
+ conversationId = '',
+): Promise {
+ const query = conversationId ? `?${new URLSearchParams({ conversation_id: conversationId }).toString()}` : '';
return actionFromResponse(
- await m365Request(`/api/msgraph/pending-actions/${encodeURIComponent(id)}`, { signal }),
+ await m365Request(`/api/msgraph/pending-actions/${encodeURIComponent(id)}${query}`, { signal }),
id,
);
}
diff --git a/application/v2_ui/src/lib/collaborationEvents.ts b/application/v2_ui/src/lib/collaborationEvents.ts
index fbd6e9a15..941607fc3 100644
--- a/application/v2_ui/src/lib/collaborationEvents.ts
+++ b/application/v2_ui/src/lib/collaborationEvents.ts
@@ -46,6 +46,12 @@ const REPLAY_TOLERANCE_MS = 1000;
*/
const MAX_REMEMBERED_EVENTS = 2000;
+/** The event the server publishes when a shared conversation's AI request saves an outgoing action. */
+const PENDING_ACTION_EVENT = 'collaboration.m365.pending_action';
+
+/** The `error` the server sends when it could not project the saved actions for a viewer. */
+const PENDING_ACTIONS_UNAVAILABLE = 'm365_pending_actions_unavailable';
+
/**
* Fields of a serialized conversation that describe the *viewer* rather than the conversation.
*
@@ -170,6 +176,14 @@ export interface CollaborationEventHandlers {
* before the reader opened the conversation is still running, and only its history says so.
*/
onAiActivity?: (event: AiActivityEvent) => void;
+ /**
+ * An AI request in the conversation saved a Microsoft 365 outgoing action.
+ *
+ * The event only names the actions (or says they could not be listed), and what a viewer may
+ * see of them differs per person, so the handler reads the conversation's list again rather
+ * than trusting the payload.
+ */
+ onM365PendingActions?: (event: { actionIds: string[]; requestId: string; unavailable: boolean }) => void;
}
/**
@@ -182,13 +196,20 @@ export interface CollaborationEventHandlers {
function eventKey(event: CollaborationEvent): string {
const payload = event.payload ?? {};
const run = payload.run as { run_id?: unknown } | undefined;
+ // An outgoing-action event names no message, so the actions it announces and the request
+ // that saved them tell two such events apart.
+ const pendingActions =
+ event.event_type === PENDING_ACTION_EVENT
+ ? JSON.stringify([payload.m365_pending_action_ids ?? [], payload.request_id ?? ''])
+ : '';
const subject =
- payload.message?.id ??
- payload.message_id ??
- payload.participant?.user_id ??
- payload.user?.user_id ??
- payload.deleted_by_user_id ??
- (typeof run?.run_id === 'string' ? run.run_id : '');
+ pendingActions ||
+ (payload.message?.id ??
+ payload.message_id ??
+ payload.participant?.user_id ??
+ payload.user?.user_id ??
+ payload.deleted_by_user_id ??
+ (typeof run?.run_id === 'string' ? run.run_id : ''));
return [
event.conversation_id ?? payload.conversation?.id ?? '',
event.event_type ?? '',
@@ -326,6 +347,18 @@ export function dispatchCollaborationEvent(
}
return;
+ case PENDING_ACTION_EVENT: {
+ const ids = payload.m365_pending_action_ids;
+ handlers.onM365PendingActions?.({
+ actionIds: Array.isArray(ids)
+ ? ids.filter((id): id is string => typeof id === 'string' && Boolean(id))
+ : [],
+ requestId: typeof payload.request_id === 'string' ? payload.request_id : '',
+ unavailable: payload.error === PENDING_ACTIONS_UNAVAILABLE,
+ });
+ return;
+ }
+
case 'collaboration.typing.updated':
handlers.onTyping?.(payload.user, payload.is_typing !== false, payload.expires_at);
return;
diff --git a/application/v2_ui/src/lib/conversationUrl.ts b/application/v2_ui/src/lib/conversationUrl.ts
index dffafad6f..53ee3e9e8 100644
--- a/application/v2_ui/src/lib/conversationUrl.ts
+++ b/application/v2_ui/src/lib/conversationUrl.ts
@@ -50,6 +50,36 @@ export const NEW_CHAT_PARAM = 'new';
export const RESULT_WORKFLOW_PARAM = 'result_workflow_id';
export const RESULT_RUN_PARAM = 'result_run_id';
+/**
+ * A one-shot parameter that scrolls the opened conversation to a saved Microsoft 365 outgoing
+ * action and highlights it, as `/chat?conversationId=&m365_pending_action=`.
+ *
+ * Like `prompt`, the chat page reads it once while it first renders and `syncedConversationParams`
+ * strips it afterwards, so a reload or a copied address does not keep pulling the page back to
+ * the card. The id is a request, never authorization: the card only appears when the server
+ * returns that action for this conversation.
+ */
+export const M365_PENDING_ACTION_PARAM = 'm365_pending_action';
+
+/**
+ * A saved-action id that is safe to put in a URL or a selector, or '' when it is not.
+ *
+ * The server only issues opaque ids, so anything with a path, query or control character
+ * (including values pasted into a link by hand) is refused instead of being cleaned up.
+ */
+export function normalizePendingActionId(value: unknown): string {
+ if (typeof value !== 'string') return '';
+ const id = value.trim();
+ if (!id || id.length > 200 || id === '.' || id === '..') return '';
+ // eslint-disable-next-line no-control-regex
+ return /[\u0000-\u001f\u007f/\\?#]/.test(id) ? '' : id;
+}
+
+/** The saved action a set of query parameters asks to focus, or null when it names none or names it badly. */
+export function readPendingActionFocus(params: URLSearchParams): string | null {
+ return normalizePendingActionId(params.get(M365_PENDING_ACTION_PARAM)) || null;
+}
+
/**
* The prompt a set of query parameters names, or null when it names none.
*
@@ -165,6 +195,17 @@ export function chatHrefForConversation(conversationId: string): string {
return `/chat?${CONVERSATION_PARAM}=${encodeURIComponent(conversationId)}`;
}
+/**
+ * A link that opens a conversation and scrolls to one of its saved Microsoft 365 outgoing
+ * actions. An id that cannot be carried safely opens the conversation without the focus
+ * request instead of dropping the whole link.
+ */
+export function chatHrefForPendingAction(conversationId: string, pendingActionId: string): string {
+ const base = chatHrefForConversation(conversationId);
+ const id = normalizePendingActionId(pendingActionId);
+ return id ? `${base}&${M365_PENDING_ACTION_PARAM}=${encodeURIComponent(id)}` : base;
+}
+
/**
* Also accepted when reading, never written.
*
@@ -217,9 +258,10 @@ export function syncedConversationParams(
const hasAgentLaunch = params.has(WORKSPACE_AGENT_PARAM) || params.has(AGENT_SCOPE_PARAM)
|| params.has(AGENT_SCOPE_ID_PARAM);
const hasResultLaunch = hasWorkflowResultLaunch(params);
+ const hasPendingActionFocus = params.has(M365_PENDING_ACTION_PARAM);
if (!hasLegacy && !hasPrompt && !hasPromptScope && !hasAgentLaunch && !hasResultLaunch
- && (current ?? null) === conversationId) {
+ && !hasPendingActionFocus && (current ?? null) === conversationId) {
return null;
}
@@ -228,6 +270,7 @@ export function syncedConversationParams(
next.delete(PROMPT_PARAM);
next.delete(PROMPT_SCOPE_PARAM);
next.delete(PROMPT_SCOPE_ID_PARAM);
+ next.delete(M365_PENDING_ACTION_PARAM);
if (hasAgentLaunch) {
next.delete(WORKSPACE_AGENT_PARAM);
next.delete(AGENT_SCOPE_PARAM);
diff --git a/application/v2_ui/src/lib/m365PendingActions.ts b/application/v2_ui/src/lib/m365PendingActions.ts
new file mode 100644
index 000000000..3720b6baf
--- /dev/null
+++ b/application/v2_ui/src/lib/m365PendingActions.ts
@@ -0,0 +1,345 @@
+// m365PendingActions.ts
+// Pure rules for the Microsoft 365 outgoing-action cards (no React, no network).
+//
+// A card is a saved email or calendar invitation that is waiting for the owner to send or
+// cancel it. These helpers decide which server snapshot to trust, where a card belongs in the
+// conversation and what a stream frame or history message is carrying. They mirror the classic
+// m365-pending-actions.js behavior so both interfaces treat the same saved action the same way.
+
+import {
+ ACTIONABLE_PENDING_STATUSES,
+ isPendingAction,
+ pendingActionNeedsFullReview,
+ type PendingAction,
+} from './approvalsApi';
+import { normalizePendingActionId } from './conversationUrl';
+
+export type PendingActionTone = 'info' | 'warn' | 'danger' | 'ok';
+
+export interface PendingActionNotice {
+ text: string;
+ tone: PendingActionTone;
+}
+
+/** Where in the conversation a card was seen. Every field is a trimmed string, '' when unknown. */
+export interface PendingActionReference {
+ messageId: string;
+ userMessageId: string;
+ fallbackMessageId: string;
+ requestId: string;
+}
+
+export const EMPTY_PENDING_ACTION_REFERENCE: PendingActionReference = Object.freeze({
+ messageId: '',
+ userMessageId: '',
+ fallbackMessageId: '',
+ requestId: '',
+});
+
+/** The slot that renders cards for the reply that is still streaming. */
+export const STREAMING_ANCHOR = '@streaming';
+
+/** The conversation-level section for cards that have no visible originating message. */
+export const ORPHAN_ANCHOR = '';
+
+/** How long a card waits before it asks the server about a due or in-progress action again. */
+export const PENDING_ACTION_POLL_MS = 15_000;
+
+export const PENDING_ACTIONS_UNAVAILABLE_MESSAGE =
+ 'Microsoft 365 actions were saved, but their cards could not be loaded. Reload the conversation to recover them. Do not repeat the request.';
+
+/* Links ---------------------------------------------------------------------- */
+
+/** An https link without credentials, or '' so the card shows no link at all. */
+export function safeWebLinkUrl(value: unknown): string {
+ if (typeof value !== 'string' || !value) return '';
+ try {
+ const url = new URL(value);
+ return url.protocol === 'https:' && !url.username && !url.password ? url.href : '';
+ } catch {
+ return '';
+ }
+}
+
+/** The in-app route for a saved sharing decision, falling back to the Approvals list. */
+export function safeApprovalDecisionHref(value: unknown): string {
+ return typeof value === 'string' && value ? `/approvals/m365/${encodeURIComponent(value)}` : '/approvals/m365';
+}
+
+/* Display -------------------------------------------------------------------- */
+
+export function countdownText(action: PendingAction, now: number = Date.now()): string {
+ const due = Date.parse(action.auto_send_at_utc ?? '');
+ if (!Number.isFinite(due)) return 'The scheduled time is unavailable. Refresh status to check it.';
+ const seconds = Math.max(0, Math.ceil((due - now) / 1000));
+ return seconds
+ ? `Scheduled in ${Math.floor(seconds / 60)}m ${seconds % 60}s.`
+ : 'Scheduled time reached. Waiting for the server delivery status.';
+}
+
+export function actionSubject(action: PendingAction): string {
+ return action.subject || action.summary?.subject || '(No subject)';
+}
+
+/* Identity ------------------------------------------------------------------- */
+
+// The id rule lives with the chat URL vocabulary so a link and a card agree on what is safe.
+export { normalizePendingActionId };
+
+function text(value: unknown): string {
+ return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : '';
+}
+
+/** Merge references, letting any field the newer one knows replace what the older one had. */
+export function mergeReference(
+ current: PendingActionReference,
+ next: Partial | null | undefined,
+): PendingActionReference {
+ if (!next) return current;
+ const merged: PendingActionReference = {
+ messageId: text(next.messageId) || current.messageId,
+ userMessageId: text(next.userMessageId) || current.userMessageId,
+ fallbackMessageId: text(next.fallbackMessageId) || current.fallbackMessageId,
+ requestId: text(next.requestId) || current.requestId,
+ };
+ return merged.messageId === current.messageId &&
+ merged.userMessageId === current.userMessageId &&
+ merged.fallbackMessageId === current.fallbackMessageId &&
+ merged.requestId === current.requestId
+ ? current
+ : merged;
+}
+
+/* Which snapshot to trust ---------------------------------------------------- */
+
+export function isActionableStatus(status: string | undefined): boolean {
+ return ACTIONABLE_PENDING_STATUSES.has(status ?? '');
+}
+
+/**
+ * Whether a snapshot must be ignored because the card already holds something newer.
+ *
+ * A late list page or stream frame must never move a finished action back to a sendable state,
+ * and a version the card has already moved past must not come back.
+ */
+export function isStaleSnapshot(
+ previous: PendingAction,
+ seenVersions: ReadonlySet,
+ next: PendingAction,
+ authoritative: boolean,
+): boolean {
+ const older = Date.parse(next.updated_at ?? '') < Date.parse(previous.updated_at ?? '');
+ const terminalRegression =
+ !authoritative && !isActionableStatus(previous.status) && isActionableStatus(next.status);
+ const revisited = next.version !== previous.version && seenVersions.has(next.version ?? '');
+ return older || terminalRegression || revisited;
+}
+
+/** The permissions the owner still has to grant, or null when nothing is needed or it was acknowledged. */
+export function hydratedAuth(
+ action: PendingAction,
+ authoritative: boolean,
+ acknowledgedVersion: string,
+ current: Record | null,
+): { auth: Record | null; acknowledgedVersion: string } {
+ if (action.viewer_is_owner === false) return { auth: null, acknowledgedVersion: '' };
+ if (action.auth_required === true) {
+ if (action.version && acknowledgedVersion === action.version) {
+ return { auth: null, acknowledgedVersion };
+ }
+ return {
+ auth: {
+ auth_required: true,
+ sources: Array.isArray(action.sources)
+ ? action.sources
+ : [action.graph_resource_type === 'calendar' ? 'calendar' : 'email'],
+ scopes: Array.isArray(action.scopes) ? action.scopes : [],
+ },
+ acknowledgedVersion,
+ };
+ }
+ if (authoritative && action.auth_required === false) return { auth: null, acknowledgedVersion };
+ return { auth: current, acknowledgedVersion };
+}
+
+/** Whether the complete saved content was already loaded for this exact version. */
+export function hasLoadedFullDetails(action: PendingAction, authoritative: boolean): boolean {
+ return (
+ authoritative &&
+ action.review_details_required === false &&
+ !pendingActionNeedsFullReview(action) &&
+ typeof action.summary?.body_preview === 'string'
+ );
+}
+
+/* Polling -------------------------------------------------------------------- */
+
+/** A scheduled action that will be sent by the server, with a usable time. */
+export function isScheduled(action: PendingAction): boolean {
+ return (
+ action.will_auto_send === true &&
+ isActionableStatus(action.status) &&
+ Number.isFinite(Date.parse(action.auto_send_at_utc ?? ''))
+ );
+}
+
+/** Whether the card should tick once a second (to show the countdown or watch a send finish). */
+export function needsTicking(action: PendingAction): boolean {
+ return (action.will_auto_send === true && isActionableStatus(action.status)) || action.status === 'sending';
+}
+
+/** Whether a tick should ask the server again: the send time passed, or a send is in progress. */
+export function isPollDue(action: PendingAction, busy: boolean, nextRefreshAt: number, now: number = Date.now()): boolean {
+ if (busy || now < nextRefreshAt) return false;
+ const due = Date.parse(action.auto_send_at_utc ?? '');
+ return (isScheduled(action) && due <= now) || action.status === 'sending';
+}
+
+/* Reading what the server sent ----------------------------------------------- */
+
+/** Every saved action an event, frame or message carries, ignoring anything that is not one. */
+export function pendingActionsFromPayload(payload: unknown): PendingAction[] {
+ if (!payload || typeof payload !== 'object') return [];
+ const record = payload as { pending_action?: unknown; m365_pending_actions?: unknown };
+ const found: PendingAction[] = [];
+ if (isPendingAction(record.pending_action)) found.push(record.pending_action);
+ if (Array.isArray(record.m365_pending_actions)) {
+ for (const item of record.m365_pending_actions) {
+ if (isPendingAction(item)) found.push(item);
+ }
+ }
+ return found;
+}
+
+/** The error message when the server saved actions but could not build their cards. */
+export function pendingActionsErrorFromPayload(payload: unknown): string {
+ if (!payload || typeof payload !== 'object') return '';
+ const failure = (payload as { m365_pending_actions_error?: unknown }).m365_pending_actions_error;
+ if (!failure) return '';
+ const message = typeof failure === 'object' ? text((failure as { message?: unknown }).message) : text(failure);
+ return message || PENDING_ACTIONS_UNAVAILABLE_MESSAGE;
+}
+
+/** Ids a message says belong to it, whether or not their cards were loaded with it. */
+export function referencedActionIds(message: unknown): string[] {
+ const ids = (message as { metadata?: { m365_pending_action_ids?: unknown } } | null)?.metadata
+ ?.m365_pending_action_ids;
+ if (!Array.isArray(ids)) return [];
+ const unique = new Set();
+ for (const value of ids) {
+ const id = normalizePendingActionId(value);
+ if (id) unique.add(id);
+ }
+ return [...unique];
+}
+
+/** The reference a history or live message gives to the cards it carries. */
+export function referenceForMessage(message: unknown): PendingActionReference {
+ const record = (message ?? {}) as {
+ id?: unknown;
+ message_id?: unknown;
+ request_id?: unknown;
+ metadata?: { m365_request_id?: unknown };
+ };
+ return {
+ ...EMPTY_PENDING_ACTION_REFERENCE,
+ messageId: text(record.id) || text(record.message_id),
+ requestId: text(record.metadata?.m365_request_id) || text(record.request_id),
+ };
+}
+
+/**
+ * The reference for cards that arrive on a chat stream frame.
+ *
+ * A creation frame (`m365_pending_action`) has no reply yet, so its message id is never used;
+ * the card is placed under the user turn that caused it until the reply is saved.
+ */
+export function referenceForStreamFrame(
+ frame: unknown,
+ options: { messageId?: string; userMessageId?: string; requestId?: string } = {},
+): PendingActionReference {
+ const record = (frame ?? {}) as {
+ type?: unknown;
+ message_id?: unknown;
+ user_message_id?: unknown;
+ request_id?: unknown;
+ metadata?: { m365_request_id?: unknown };
+ };
+ const fromFrame = record.type === 'm365_pending_action' ? '' : text(record.message_id);
+ return {
+ messageId: text(options.messageId) || fromFrame,
+ userMessageId: text(record.user_message_id) || text(options.userMessageId),
+ fallbackMessageId: text(options.userMessageId),
+ requestId: text(record.request_id) || text(record.metadata?.m365_request_id) || text(options.requestId),
+ };
+}
+
+/** A signature that changes only when a message brings new or different cards. */
+export function messageTrackKey(message: unknown): string {
+ const record = (message ?? {}) as { id?: unknown; message_id?: unknown; m365_pending_actions?: unknown };
+ const messageId = text(record.id) || text(record.message_id);
+ if (!messageId) return '';
+ const cards = Array.isArray(record.m365_pending_actions)
+ ? record.m365_pending_actions
+ .filter(isPendingAction)
+ .map((action) => `${action.id}@${action.version ?? ''}@${action.updated_at ?? ''}@${action.status}`)
+ : [];
+ const ids = referencedActionIds(message);
+ if (!cards.length && !ids.length) return '';
+ return [messageId, referenceForMessage(message).requestId, cards.join(','), ids.join(',')].join('|');
+}
+
+/* Placement ------------------------------------------------------------------ */
+
+export interface AnchorContext {
+ /** Ids of the messages the thread is showing. */
+ visibleMessageIds: ReadonlySet;
+ /** For each request id, the last visible message that belongs to it. */
+ lastMessageByRequestId: ReadonlyMap;
+ /** The user message whose reply is still streaming, or '' when nothing is streaming. */
+ streamingUserMessageId: string;
+}
+
+/**
+ * Where a card is drawn: the id of a visible message, the streaming slot, or the
+ * conversation-level section when nothing visible claims it.
+ */
+export function resolveAnchor(
+ reference: PendingActionReference,
+ action: Pick,
+ context: AnchorContext,
+): string {
+ const visible = (id: string) => Boolean(id) && context.visibleMessageIds.has(id);
+ if (visible(reference.messageId)) return reference.messageId;
+ const streaming = context.streamingUserMessageId;
+ if (streaming && (reference.userMessageId === streaming || reference.fallbackMessageId === streaming)) {
+ return STREAMING_ANCHOR;
+ }
+ if (visible(reference.userMessageId)) return reference.userMessageId;
+ if (visible(reference.fallbackMessageId)) return reference.fallbackMessageId;
+ const requestId = reference.requestId || text(action.request_id);
+ const byRequest = requestId ? context.lastMessageByRequestId.get(requestId) : undefined;
+ return byRequest && context.visibleMessageIds.has(byRequest) ? byRequest : ORPHAN_ANCHOR;
+}
+
+/** Index the visible messages by request id so anchors do not scan the thread per card. */
+export function lastMessageByRequestId(
+ messages: ReadonlyArray<{ id?: unknown; metadata?: { m365_request_id?: unknown } }>,
+): Map {
+ const byRequest = new Map();
+ for (const message of messages) {
+ const id = text(message.id);
+ const requestId = text(message.metadata?.m365_request_id);
+ if (id && requestId) byRequest.set(requestId, id);
+ }
+ return byRequest;
+}
+
+/** The newest user message in a thread: the turn a reply that is still streaming answers. */
+export function latestUserMessageId(messages: ReadonlyArray<{ id?: unknown; role?: unknown }>): string {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ const message = messages[index];
+ if (message.role === 'user' && text(message.id)) return text(message.id);
+ }
+ return '';
+}
diff --git a/application/v2_ui/src/lib/notificationLinks.ts b/application/v2_ui/src/lib/notificationLinks.ts
index b2817aa73..6ba4e01a0 100644
--- a/application/v2_ui/src/lib/notificationLinks.ts
+++ b/application/v2_ui/src/lib/notificationLinks.ts
@@ -20,15 +20,20 @@
import type { AppNotification } from './notifications';
import type { WorkflowScope } from './workflowEditor';
-import { readConversationParam } from './conversationUrl';
+import { readConversationParam, readPendingActionFocus } from './conversationUrl';
import { groupWorkspaceDocumentPath, groupWorkspacePath } from './groupWorkspaceNavigation';
import { publicWorkspacePath } from './publicWorkspaceNavigation';
import { workflowRunHref } from './workflowRunLink';
import { requireWorkspaceId } from './workspaceContext';
export type NotificationTarget =
- /** A conversation, opened on the chat page. */
- | { kind: 'conversation'; conversationId: string }
+ /**
+ * A conversation, opened on the chat page.
+ *
+ * `pendingActionId` is a Microsoft 365 action saved in that conversation. The chat page
+ * scrolls to its card and highlights it; it is never acted on.
+ */
+ | { kind: 'conversation'; conversationId: string; pendingActionId?: string }
/** A V2 route, relative to the router's `/v2` base. */
| { kind: 'route'; path: string }
/**
@@ -110,8 +115,11 @@ const WORKFLOW_NOTIFICATION_TYPES: ReadonlySet = new Set([
]);
/**
- * Whether the notice is about a Microsoft 365 action. Those are resolved on classic pages,
- * which are the only ones that render the pending action, wherever the action id was written.
+ * Whether the notice is about a Microsoft 365 action, wherever the action id was written.
+ *
+ * A notice that names a workflow run keeps its classic workflow-activity page: that page draws
+ * the saved action beside the run, and V2's run page does not. A notice about an action in a
+ * conversation does not need this; it opens the conversation (see `chatTarget`).
*/
function isMicrosoft365Notice(notification: AppNotification): boolean {
return 'm365_pending_action_id' in notification.metadata
@@ -195,6 +203,11 @@ function groupIdFor(notification: AppNotification): string | null {
/**
* A chat link. The conversation id is read with both spellings in circulation, the same
* way the chat page reads it (conversationUrl.ts readConversationParam).
+ *
+ * A Microsoft 365 notice also names the saved action to bring into view
+ * (`m365_pending_action`). An id that is not a plausible action id is left out rather than
+ * refusing the link: the conversation is still the right place to land, and nothing is ever
+ * sent by opening it.
*/
function chatTarget(url: URL): ResolvedNotificationLink {
const conversationId = readConversationParam(url.searchParams);
@@ -205,7 +218,13 @@ function chatTarget(url: URL): ResolvedNotificationLink {
if (!id) {
return { target: null, error: INVALID_LINK };
}
- return { target: { kind: 'conversation', conversationId: id }, error: null };
+ const pendingActionId = readPendingActionFocus(url.searchParams);
+ return {
+ target: pendingActionId
+ ? { kind: 'conversation', conversationId: id, pendingActionId }
+ : { kind: 'conversation', conversationId: id },
+ error: null,
+ };
}
/**
@@ -358,11 +377,6 @@ export function resolveNotificationLink(
// No empty segments are left to collapse, so only a trailing slash is set aside.
const path = url.pathname.replace(/(.)\/$/, '$1');
- // A Microsoft 365 action waiting in a conversation is resolved on the classic chat page,
- // which is the only one that renders the pending-action card.
- if ((path === '/chats' || path === '/chat' || path === '/v2/chat') && url.searchParams.has('m365_pending_action')) {
- return classic(`/chats${url.search}${url.hash}`, notification, origin);
- }
if (path === '/chats' || path === '/chat' || path === '/v2/chat') {
return chatTarget(url);
}
diff --git a/application/v2_ui/src/lib/notificationNavigation.ts b/application/v2_ui/src/lib/notificationNavigation.ts
index 296a10a4a..8f9dcadaf 100644
--- a/application/v2_ui/src/lib/notificationNavigation.ts
+++ b/application/v2_ui/src/lib/notificationNavigation.ts
@@ -5,9 +5,10 @@
// conversation the same way whichever of the two the user clicked.
import { OFF_SITE_LINK, sameSiteAddress, type NotificationTarget } from './notificationLinks';
-import { chatHrefForConversation } from './conversationUrl';
+import { chatHrefForConversation, chatHrefForPendingAction } from './conversationUrl';
import { GROUP_WORKSPACES } from './workspaces';
import { useChatStore } from '../stores/chatStore';
+import { chatPendingActionsStore } from '../stores/m365PendingActionsStore';
import { toast } from '../stores/toastStore';
export interface NotificationNavigationContext {
@@ -24,19 +25,32 @@ export interface NotificationNavigationContext {
* leave the old thread on screen. On the chat page the store opens the conversation directly
* instead -- and reports a deleted or inaccessible one itself -- while the page's own URL sync
* writes the address bar to match. Anywhere else the link is simply followed.
+ *
+ * `pendingActionId` is a saved Microsoft 365 action to bring into view once the conversation
+ * is open. On the chat page it is asked for before the conversation is opened: the store keeps
+ * a request for the conversation it is switching to, and the thread acts on it when the card is
+ * on screen. Anywhere else it travels in the address, where the chat page picks it up.
*/
export function openConversationFromNotification(
conversationId: string,
context: NotificationNavigationContext,
+ pendingActionId?: string,
): void {
if (context.pathname === '/chat') {
+ if (pendingActionId) {
+ chatPendingActionsStore.getState().requestFocus(pendingActionId, conversationId);
+ }
const chat = useChatStore.getState();
if (chat.activeConversationId !== conversationId) {
void chat.openLinkedConversation(conversationId);
}
return;
}
- context.navigate(chatHrefForConversation(conversationId));
+ context.navigate(
+ pendingActionId
+ ? chatHrefForPendingAction(conversationId, pendingActionId)
+ : chatHrefForConversation(conversationId),
+ );
}
/**
@@ -54,7 +68,7 @@ export async function openNotificationTarget(
context: NotificationNavigationContext,
): Promise {
if (target.kind === 'conversation') {
- openConversationFromNotification(target.conversationId, context);
+ openConversationFromNotification(target.conversationId, context, target.pendingActionId);
return;
}
if (target.kind === 'route') {
diff --git a/application/v2_ui/src/lib/sse.ts b/application/v2_ui/src/lib/sse.ts
index a7dddea6c..2128dc8a2 100644
--- a/application/v2_ui/src/lib/sse.ts
+++ b/application/v2_ui/src/lib/sse.ts
@@ -52,6 +52,16 @@ export function parseSseEventPayload(eventBlock: string): string | null {
return dataLines.map((line) => line.substring(5).trimStart()).join('\n');
}
+/** True for a frame that announces outgoing Microsoft 365 actions or says they could not be listed. */
+export function carriesPendingActions(event: ChatStreamEvent | null | undefined): event is ChatStreamEvent {
+ return (
+ !!event &&
+ (event.type === 'm365_pending_action' ||
+ event.m365_pending_actions !== undefined ||
+ event.m365_pending_actions_error !== undefined)
+ );
+}
+
export interface ChatStreamHandlers {
/** A content delta arrived. Append it to the message being built. */
onContent?: (delta: string, accumulated: string) => void;
@@ -61,6 +71,12 @@ export interface ChatStreamHandlers {
onConversationMetadata?: (event: ChatStreamEvent) => void;
/** The user's message reached durable storage (`type: "user_message_persisted"`). */
onUserMessagePersisted?: (event: ChatStreamEvent) => void;
+ /**
+ * A frame carries Microsoft 365 outgoing actions: the `m365_pending_action` frame sent the
+ * moment one is saved, or a terminal frame listing them. It does not end the stream, and a
+ * terminal frame is still delivered to its own handler afterwards.
+ */
+ onM365PendingActions?: (event: ChatStreamEvent) => void;
/** Terminal frame carrying the final assistant message and its metadata. */
onDone?: (event: ChatStreamEvent, accumulated: string) => void;
/** The stream was cancelled, either by the user or server-side. */
@@ -268,6 +284,8 @@ async function consumeStreamResponse(
): Promise {
/** Returns true when the frame was terminal and reading should stop. */
const handleEvent = (event: ChatStreamEvent): boolean => {
+ // Reported before the error check: a failed run can still have saved an action.
+ if (carriesPendingActions(event)) handlers.onM365PendingActions?.(event);
if (event.replace_content === true) {
result.accumulated = resolveStreamContent(event, '');
handlers.onContent?.('', result.accumulated);
@@ -293,6 +311,11 @@ async function consumeStreamResponse(
return false;
}
+ // Announced above; the frame carries no answer text of its own.
+ if (event.type === 'm365_pending_action') {
+ return false;
+ }
+
if (event.replace_content !== true && typeof event.content === 'string' && event.content.length > 0) {
result.accumulated += event.content;
handlers.onContent?.(event.content, result.accumulated);
@@ -530,6 +553,8 @@ export async function streamChat(
} catch {
/* Non-JSON error body; keep the status-based message. */
}
+ // A refused request can still say that its outgoing actions could not be listed.
+ if (carriesPendingActions(event)) handlers.onM365PendingActions?.(event);
// Reported through onError and returned rather than thrown: failure is already
// modelled by result.errored, and throwing here would escape as an unhandled
// rejection and skip the caller's post-stream cleanup.
diff --git a/application/v2_ui/src/lib/types.ts b/application/v2_ui/src/lib/types.ts
index ccf9b176a..7032a1db6 100644
--- a/application/v2_ui/src/lib/types.ts
+++ b/application/v2_ui/src/lib/types.ts
@@ -1339,6 +1339,15 @@ export interface ChatStreamEvent {
agent_display_name?: string;
augmented?: boolean;
metadata?: Json;
+ /** The outgoing Microsoft 365 action a `type: "m365_pending_action"` frame announces. */
+ pending_action?: Json;
+ /** Outgoing actions a terminal frame carries for the chat run that saved them. */
+ m365_pending_actions?: Json;
+ /** Set when those actions could not be listed; the conversation's list is the fallback. */
+ m365_pending_actions_error?: Json;
+ request_id?: string;
+ /** A shared conversation's frame names the user turn in the hidden source conversation. */
+ m365_source_user_message_id?: string;
[key: string]: unknown;
}
diff --git a/application/v2_ui/src/pages/ChatPage.tsx b/application/v2_ui/src/pages/ChatPage.tsx
index 997f61898..c299e310a 100644
--- a/application/v2_ui/src/pages/ChatPage.tsx
+++ b/application/v2_ui/src/pages/ChatPage.tsx
@@ -22,6 +22,7 @@ import {
} from '../stores/generatedDocumentsStore';
import { useUiStore } from '../stores/uiStore';
import { useImageProposalStore } from '../stores/imageProposalStore';
+import { chatPendingActionsStore } from '../stores/m365PendingActionsStore';
import {
selectActiveTurn,
selectPlan,
@@ -33,6 +34,7 @@ import { resumeOrchestrationForConversation } from '../lib/orchestrationResume';
import {
hasWorkflowResultLaunch,
readConversationParam,
+ readPendingActionFocus,
readWorkflowResultLaunch,
syncedConversationParams,
} from '../lib/conversationUrl';
@@ -93,6 +95,19 @@ function useConversationUrlSync() {
// that effect must re-run once the link has been dealt with.
const linkConsumed = useRef(false);
const [linkHandled, setLinkHandled] = useState(!linkedConversationId);
+ // A Microsoft 365 notice names the saved action to bring into view. Like the conversation
+ // id it is captured during the first render and asked for once; the store holds the request
+ // across the conversation opening, and the thread acts on it once the card is on screen.
+ const [pendingActionFocus] = useState(() => readPendingActionFocus(searchParams));
+ const pendingActionRequested = useRef(false);
+
+ useEffect(() => {
+ if (pendingActionRequested.current || !pendingActionFocus || !linkedConversationId) {
+ return;
+ }
+ pendingActionRequested.current = true;
+ chatPendingActionsStore.getState().requestFocus(pendingActionFocus, linkedConversationId);
+ }, [pendingActionFocus, linkedConversationId]);
useEffect(() => {
if (linkConsumed.current || !linkedConversationId) {
diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts
index 8a1a2066d..998975a55 100644
--- a/application/v2_ui/src/stores/chatStore.ts
+++ b/application/v2_ui/src/stores/chatStore.ts
@@ -143,6 +143,8 @@ import { readConversationParam } from '../lib/conversationUrl';
import { refreshNotificationCount } from './notificationStore';
import { useBootstrapStore } from './bootstrapStore';
import { useCollaborationStore, participantName } from './collaborationStore';
+import { chatPendingActionsStore } from './m365PendingActionsStore';
+import { latestUserMessageId } from '../lib/m365PendingActions';
import type { MaskAction, MaskSelection } from '../lib/masking';
import type { VisualStyle } from '../lib/visualPalettes';
import {
@@ -953,6 +955,10 @@ function detachActiveStream(): void {
activeStreamController.abort();
activeStreamController = null;
streamingConversationId = null;
+ // The reply is no longer being read, so its turn stops being a streaming one. Left
+ // registered, the next send in this conversation would take the old turn for a renamed
+ // one and pull the stopped reply's saved Microsoft 365 actions down with it.
+ chatPendingActionsStore.getState().setLiveStream(null);
// Included here rather than left to callers: `stopStreaming` has no state reset of its
// own, so dropping it would leave the composer stuck showing Stop for a stream that is no
// longer being read. `selectConversation` and `startNewConversation` also set `streaming`
@@ -960,6 +966,22 @@ function detachActiveStream(): void {
useChatStore.setState({ streaming: false, streamingContent: '', reconnectPhase: null });
}
+/**
+ * Read the open conversation's saved Microsoft 365 actions again once a reply is over.
+ *
+ * A reply can save an action its own stream never described: the connection drops, or the
+ * model fails after the tool ran. The list is the only complete answer, so the classic client
+ * reads it after every ending (chat-streaming.js:777). Skipped when the reader has moved on,
+ * because a conversation's list is read when it is opened.
+ */
+function refreshPendingActionsAfterReply(conversationId: string): void {
+ const pendingActions = chatPendingActionsStore.getState();
+ if (pendingActions.conversationId !== conversationId) {
+ return;
+ }
+ void pendingActions.refreshList();
+}
+
/**
* Detach from the open shared conversation's event stream, if there is one.
*
@@ -1283,9 +1305,32 @@ function buildStreamHandlers(
const analysisRevision = getState().analysisContextRevision;
const completionMetadata = (event: ChatStreamEvent) =>
reasoningMetadataForEvent(event, getState().streamingReasoningAdjustments);
+ // The user turn this stream answers, which is where an outgoing Microsoft 365 action it
+ // saves is drawn until the reply exists. Swapped for the server's id once the optimistic
+ // message is persisted.
+ let streamUserMessageId =
+ String(pendingUserMessageId ?? '').trim() || latestUserMessageId(getState().messages);
return {
+ onM365PendingActions: (event) => {
+ // The viewed conversation's id rather than the frame's own: a shared conversation's
+ // frames can carry the id of the hidden source conversation.
+ chatPendingActionsStore.getState().handleStreamPayload(event, {
+ conversationId,
+ messageId: '',
+ userMessageId: streamUserMessageId,
+ requestId: '',
+ });
+ },
onUserMessagePersisted: (event) => {
const persistedId = String(event.user_message_id ?? event.message_id ?? '').trim();
+ if (persistedId) {
+ streamUserMessageId = persistedId;
+ if (isCurrent()) {
+ chatPendingActionsStore
+ .getState()
+ .setLiveStream({ conversationId, userMessageId: persistedId });
+ }
+ }
if (!persistedId || !pendingUserMessageId || !isCurrent()) {
return;
}
@@ -1532,6 +1577,17 @@ async function runChatStream(
const isCurrent = () =>
ownsController() && getState().activeConversationId === conversationId;
+ // The user turn this stream answers is where an outgoing Microsoft 365 action it saves is
+ // drawn until the reply exists. Registered after the conversation is active, because
+ // switching conversations clears it.
+ const liveUserMessageId =
+ String(options.pendingUserMessageId ?? '').trim() || latestUserMessageId(getState().messages);
+ if (liveUserMessageId && isCurrent()) {
+ chatPendingActionsStore
+ .getState()
+ .setLiveStream({ conversationId, userMessageId: liveUserMessageId });
+ }
+
await streamChat(
requestBody,
buildStreamHandlers(
@@ -1553,7 +1609,9 @@ async function runChatStream(
if (ownsController()) {
activeStreamController = null;
streamingConversationId = null;
+ chatPendingActionsStore.getState().setLiveStream(null);
set({ streaming: false, reconnectPhase: null });
+ refreshPendingActionsAfterReply(conversationId);
}
// Retry and edit rewrite thread state server-side, so the authoritative message list
@@ -1621,6 +1679,13 @@ async function resumeChatStream(conversationId: string): Promise {
reconnectPhase: 'connecting',
});
+ const reattachedUserMessageId = latestUserMessageId(getState().messages);
+ if (reattachedUserMessageId) {
+ chatPendingActionsStore
+ .getState()
+ .setLiveStream({ conversationId, userMessageId: reattachedUserMessageId });
+ }
+
await reattachChatStream(
conversationId,
buildStreamHandlers(conversationId, isCurrent, set, getState),
@@ -1629,7 +1694,9 @@ async function resumeChatStream(conversationId: string): Promise {
if (isCurrent()) {
activeStreamController = null;
+ chatPendingActionsStore.getState().setLiveStream(null);
set({ streaming: false, reconnectPhase: null });
+ refreshPendingActionsAfterReply(conversationId);
}
return true;
@@ -1871,6 +1938,23 @@ function attachCollaborationEvents(conversationId: string): void {
collaboration().applyAiActivity(activity);
},
+ onM365PendingActions: ({ unavailable }) => {
+ if (!stillOpen()) {
+ return;
+ }
+ const pendingActions = chatPendingActionsStore.getState();
+ if (unavailable) {
+ // The server could not work out what this reader may see of the saved action,
+ // so say so and look again rather than showing a thread that seems to have none.
+ pendingActions.handleStreamPayload(
+ { m365_pending_actions_error: { error: 'm365_pending_actions_unavailable' } },
+ { conversationId },
+ );
+ return;
+ }
+ void pendingActions.refreshList();
+ },
+
onConversationUpdated: (conversation) => {
// Only the facts every reader shares. The capability flags, role, membership
// status and pin state in this payload are the acting user's, not this one's.
@@ -3294,13 +3378,17 @@ export const useChatStore = create((set, get) => ({
// written, not merely to stop being watched. Addresses the conversation the stream
// actually belongs to, which may no longer be the one on screen, through whichever
// cancel route that conversation uses.
- if (streamingConversationId) {
+ const stoppedConversationId = streamingConversationId;
+ if (stoppedConversationId) {
+ // The reply is detached below, so its ending is never seen here. Read the list
+ // once the server has the request instead. An action the reply was still saving
+ // is picked up by the next read: window focus, Refresh, or a collaboration event.
void cancelStream(
- streamingConversationId,
+ stoppedConversationId,
streamingConversationKind === 'collaborative'
- ? cancelCollaborationStreamUrl(streamingConversationId)
+ ? cancelCollaborationStreamUrl(stoppedConversationId)
: undefined,
- );
+ ).then(() => refreshPendingActionsAfterReply(stoppedConversationId));
}
detachActiveStream();
},
@@ -4437,3 +4525,19 @@ export const useChatStore = create((set, get) => ({
}
},
}));
+
+// The open conversation's outgoing Microsoft 365 actions follow the thread on screen. Done here,
+// off every change to the open conversation, rather than in each action that can change it:
+// there are many (selecting, a new chat, a deep link, a reload), and one missed would leave the
+// cards of the conversation the reader just left standing under the next one. The store is
+// pointed at the conversation first so the messages it is then shown are attributed to it.
+useChatStore.subscribe((state, previous) => {
+ const pendingActions = chatPendingActionsStore.getState();
+ const conversationChanged = state.activeConversationId !== previous.activeConversationId;
+ if (conversationChanged) {
+ pendingActions.setConversation(state.activeConversationId ?? '');
+ }
+ if (conversationChanged || state.messages !== previous.messages) {
+ pendingActions.ingestMessages(state.messages);
+ }
+});
diff --git a/application/v2_ui/src/stores/m365PendingActionsStore.ts b/application/v2_ui/src/stores/m365PendingActionsStore.ts
new file mode 100644
index 000000000..663c22391
--- /dev/null
+++ b/application/v2_ui/src/stores/m365PendingActionsStore.ts
@@ -0,0 +1,857 @@
+// m365PendingActionsStore.ts
+// State for the Microsoft 365 outgoing-action cards.
+//
+// One factory builds the store. The chat view uses a single shared instance so the chat stream
+// handlers (which run outside React) can hand it cards, and every Approvals detail builds its own
+// instance for the one action it shows. Both run the same state machine, so a card behaves the
+// same wherever it is drawn: it only ever sends or cancels the version the person reviewed, a
+// snapshot from history or a stream is checked against the server before it can be sent, and a
+// late response for a conversation the person has left is dropped.
+
+import { createContext, createElement, useContext, type ReactNode } from 'react';
+import { createStore, useStore, type StoreApi } from 'zustand';
+import { ApiError } from '../lib/apiClient';
+import {
+ errorMessage,
+ fetchConversationPendingActions,
+ fetchPendingAction,
+ isPendingAction,
+ m365ErrorPayload,
+ mutatePendingAction,
+ pendingActionNeedsFullReview,
+ pendingActionSendRoute,
+ type PendingAction,
+} from '../lib/approvalsApi';
+import { connectMicrosoft365, m365Sources } from '../lib/m365Connect';
+import {
+ EMPTY_PENDING_ACTION_REFERENCE,
+ PENDING_ACTION_POLL_MS,
+ hasLoadedFullDetails,
+ hydratedAuth,
+ isActionableStatus,
+ isPollDue,
+ isStaleSnapshot,
+ mergeReference,
+ messageTrackKey,
+ normalizePendingActionId,
+ pendingActionsErrorFromPayload,
+ pendingActionsFromPayload,
+ referenceForMessage,
+ referenceForStreamFrame,
+ referencedActionIds,
+ type PendingActionNotice,
+ type PendingActionReference,
+} from '../lib/m365PendingActions';
+
+export type PendingActionOperation = 'send-now' | 'approve' | 'cancel';
+
+export interface PendingActionEntry {
+ id: string;
+ /** Distinguishes this entry from a later one for the same id, so late responses can be dropped. */
+ key: number;
+ /** Insertion order, so cards stay in the order they were found. */
+ order: number;
+ action: PendingAction;
+ /** Versions this card has already moved past; a snapshot of one of them is ignored. */
+ seenVersions: readonly string[];
+ reference: PendingActionReference;
+ busy: boolean;
+ reviewing: boolean;
+ refreshing: boolean;
+ /** The card holds an unverified snapshot, so sending is blocked until the server confirms it. */
+ needsRefresh: boolean;
+ denied: boolean;
+ auth: Record | null;
+ authAcknowledgedVersion: string;
+ approvalRequired: Record | null;
+ /** The version whose complete saved content was loaded from the server. */
+ fullDetailsVersion: string;
+ /** The version whose body the person opened and had refreshed. */
+ detailVersion: string;
+ notice: PendingActionNotice | null;
+ bodyOpen: boolean;
+ nextRefreshAt: number;
+}
+
+export interface PendingActionDetailState {
+ status: 'loading' | 'error';
+ message: string;
+}
+
+export interface PendingActionsLiveStream {
+ conversationId: string;
+ userMessageId: string;
+}
+
+export interface PendingActionsFocusRequest {
+ id: string;
+ conversationId: string;
+ key: number;
+}
+
+export interface RefreshOptions {
+ clearNotice?: boolean;
+ acknowledgeAuthVersion?: string;
+}
+
+export interface StreamFrameOptions {
+ conversationId?: string;
+ messageId?: string;
+ userMessageId?: string;
+ requestId?: string;
+}
+
+interface PendingActionsData {
+ scope: 'chat' | 'detail';
+ conversationId: string;
+ epoch: number;
+ entries: Readonly>;
+ /** Load state for actions fetched by id (an Approvals link, or a notification deep link). */
+ detail: Readonly>;
+ listStatus: 'idle' | 'loading' | 'loaded' | 'error';
+ listError: string;
+ referenceError: string;
+ /** True while a card a message points at is still being fetched, so it may yet move. */
+ referenceLoading: boolean;
+ continuationToken: string;
+ loadingMore: boolean;
+ liveStream: PendingActionsLiveStream | null;
+ focusRequest: PendingActionsFocusRequest | null;
+}
+
+interface PendingActionsApi {
+ remember(
+ action: PendingAction,
+ options?: { authoritative?: boolean; reference?: Partial },
+ ): PendingActionEntry | null;
+ refresh(id: string, options?: RefreshOptions): Promise;
+ loadById(id: string, options?: { reference?: Partial }): Promise;
+ reviewFull(id: string): Promise;
+ submit(id: string, operation: PendingActionOperation): Promise;
+ reconnect(id: string): Promise;
+ setBodyOpen(id: string, open: boolean): void;
+ pollIfDue(id: string): void;
+ setConversation(conversationId: string): void;
+ loadList(options?: { more?: boolean }): Promise;
+ /** Read the first page again, after one already running has settled, so nothing newer is missed. */
+ refreshList(): Promise;
+ trackMessage(message: unknown, options?: { history?: boolean }): void;
+ ingestMessages(messages: readonly unknown[]): void;
+ handleStreamPayload(payload: unknown, options?: StreamFrameOptions): void;
+ setLiveStream(stream: PendingActionsLiveStream | null): void;
+ requestFocus(id: string, conversationId: string): void;
+ clearFocus(): void;
+ reset(): void;
+}
+
+export type PendingActionsStore = PendingActionsData & PendingActionsApi;
+
+export const LIST_PERMISSION_MESSAGE = 'You do not have permission to view outgoing actions for this conversation.';
+export const LIST_FAILED_MESSAGE =
+ 'Outgoing actions could not be loaded. Refresh to recover saved actions; this is not an empty inbox.';
+export const REFERENCE_FAILED_MESSAGE =
+ 'A saved Microsoft 365 action could not be recovered. Refresh outgoing actions to check its status.';
+const NOT_LOADED_MESSAGE = 'The outgoing action could not be loaded.';
+const PERMISSION_MESSAGE = 'You do not have permission to access this action. No action was sent.';
+const STATUS_UNKNOWN_MESSAGE = 'The current action status could not be checked. Refresh status before trying again.';
+
+const info = (text: string): PendingActionNotice => ({ text, tone: 'info' });
+const warn = (text: string): PendingActionNotice => ({ text, tone: 'warn' });
+
+function newEntry(action: PendingAction, key: number, order: number, reference: PendingActionReference): PendingActionEntry {
+ return {
+ id: action.id,
+ key,
+ order,
+ action,
+ seenVersions: [],
+ reference,
+ busy: false,
+ reviewing: false,
+ refreshing: false,
+ needsRefresh: false,
+ denied: false,
+ auth: null,
+ authAcknowledgedVersion: '',
+ approvalRequired: null,
+ fullDetailsVersion: '',
+ detailVersion: '',
+ notice: null,
+ bodyOpen: false,
+ nextRefreshAt: 0,
+ };
+}
+
+/** Whether the person may send or cancel this card right now. */
+export function canChangeEntry(entry: PendingActionEntry): boolean {
+ return (
+ !entry.busy &&
+ !entry.refreshing &&
+ !entry.needsRefresh &&
+ !entry.denied &&
+ entry.action.viewer_is_owner !== false &&
+ typeof entry.action.version === 'string' &&
+ Boolean(entry.action.version) &&
+ isActionableStatus(entry.action.status)
+ );
+}
+
+function statusOf(error: unknown): number {
+ return error instanceof ApiError ? error.status : 0;
+}
+
+export function createPendingActionsStore(
+ options: { scope?: 'chat' | 'detail'; onUpdated?: (action: PendingAction) => void } = {},
+): StoreApi {
+ const scope = options.scope ?? 'chat';
+ let keyCounter = 0;
+ let orderCounter = 0;
+ let focusCounter = 0;
+ // Bookkeeping that never drives rendering lives here, beside the state.
+ const refreshes = new Map>();
+ const sequences = new Map();
+ const referenceLoads = new Map>();
+ const referenceFailures = new Map();
+ const trackedMessages = new Map();
+ let listPromise: Promise | null = null;
+ let listController: AbortController | null = null;
+
+ return createStore()((set, get) => {
+ const current = (id: string, key: number): PendingActionEntry | null => {
+ const entry = get().entries[id];
+ return entry && entry.key === key ? entry : null;
+ };
+
+ const patchEntry = (
+ id: string,
+ key: number,
+ patch: Partial | ((entry: PendingActionEntry) => Partial),
+ ) => {
+ set((state) => {
+ const entry = state.entries[id];
+ if (!entry || entry.key !== key) return state;
+ const changes = typeof patch === 'function' ? patch(entry) : patch;
+ return { entries: { ...state.entries, [id]: { ...entry, ...changes } } };
+ });
+ };
+
+ const detailConversationId = (entry: PendingActionEntry): string => {
+ if (scope === 'chat') return get().conversationId;
+ return entry.action.viewer_is_owner === false ? (entry.action.conversation_id ?? '') : '';
+ };
+
+ const clearReferenceError = () => {
+ if (!referenceFailures.size && get().referenceError) set({ referenceError: '' });
+ };
+
+ /* Entries -------------------------------------------------------------- */
+
+ const remember: PendingActionsApi['remember'] = (action, rememberOptions = {}) => {
+ if (!isPendingAction(action)) return null;
+ const authoritative = rememberOptions.authoritative === true;
+ let accepted = false;
+ let restoreFullReview = false;
+ set((state) => {
+ const existing = state.entries[action.id];
+ if (!existing) {
+ accepted = true;
+ const reference = mergeReference(EMPTY_PENDING_ACTION_REFERENCE, rememberOptions.reference);
+ const created = newEntry(action, ++keyCounter, ++orderCounter, reference);
+ const auth = hydratedAuth(action, authoritative, '', null);
+ created.auth = auth.auth;
+ created.authAcknowledgedVersion = auth.acknowledgedVersion;
+ if (authoritative && hasLoadedFullDetails(action, true)) created.fullDetailsVersion = action.version ?? '';
+ return { entries: { ...state.entries, [action.id]: created } };
+ }
+ const reference = mergeReference(existing.reference, rememberOptions.reference);
+ if (isStaleSnapshot(existing.action, new Set(existing.seenVersions), action, authoritative)) {
+ if (reference === existing.reference) return state;
+ return { entries: { ...state.entries, [action.id]: { ...existing, reference } } };
+ }
+ accepted = true;
+ const next: PendingActionEntry = { ...existing, reference, action };
+ const previousVersion = existing.action.version;
+ if (previousVersion && previousVersion !== action.version) {
+ next.seenVersions = [...existing.seenVersions, previousVersion];
+ next.fullDetailsVersion = '';
+ next.authAcknowledgedVersion = '';
+ next.auth = null;
+ }
+ restoreFullReview =
+ !authoritative &&
+ pendingActionNeedsFullReview(action) &&
+ Boolean(action.version) &&
+ next.fullDetailsVersion === action.version;
+ if (authoritative) {
+ next.needsRefresh = false;
+ // The sharing decision may have been settled elsewhere; sending again asks the server.
+ next.approvalRequired = null;
+ if (hasLoadedFullDetails(action, true)) next.fullDetailsVersion = action.version ?? '';
+ else if (pendingActionNeedsFullReview(action)) next.fullDetailsVersion = '';
+ }
+ const auth = hydratedAuth(action, authoritative, next.authAcknowledgedVersion, next.auth);
+ next.auth = auth.auth;
+ next.authAcknowledgedVersion = auth.acknowledgedVersion;
+ return { entries: { ...state.entries, [action.id]: next } };
+ });
+ const entry = get().entries[action.id] ?? null;
+ if (entry && accepted) {
+ options.onUpdated?.(entry.action);
+ // Reauthorize details rather than restoring permissions from a cached full-body response.
+ if (restoreFullReview && !entry.busy) void refresh(entry.id);
+ }
+ return entry;
+ };
+
+ /** Add a card found in a message, a stream frame or a list, remembering where it was seen. */
+ const add = (
+ action: PendingAction,
+ reference: Partial,
+ addOptions: { authoritative?: boolean; history?: boolean } = {},
+ ) => {
+ const authoritative = addOptions.authoritative === true;
+ const before = get().entries[action.id];
+ const entry = remember(action, { authoritative, reference });
+ if (!entry) return;
+ referenceFailures.delete(entry.id);
+ clearReferenceError();
+ const changed = !before || before.action !== entry.action;
+ if (addOptions.history && !authoritative && changed) {
+ patchEntry(entry.id, entry.key, { needsRefresh: true });
+ if (!pendingActionNeedsFullReview(entry.action)) void refresh(entry.id);
+ }
+ };
+
+ const refresh: PendingActionsApi['refresh'] = (id, refreshOptions = {}) => {
+ const entry = get().entries[id];
+ if (!entry) return Promise.resolve(null);
+ const key = entry.key;
+ const reviewedVersion = refreshOptions.acknowledgeAuthVersion || entry.action.version || '';
+ const inFlight = refreshes.get(id);
+ if (inFlight) {
+ if (!refreshOptions.clearNotice) return inFlight;
+ return inFlight.then(() =>
+ current(id, key) ? refresh(id, { ...refreshOptions, acknowledgeAuthVersion: reviewedVersion }) : null,
+ );
+ }
+ const sequence = (sequences.get(id) ?? 0) + 1;
+ sequences.set(id, sequence);
+ patchEntry(id, key, { refreshing: true, nextRefreshAt: Date.now() + PENDING_ACTION_POLL_MS });
+ const isCurrent = () => Boolean(current(id, key)) && sequences.get(id) === sequence;
+ let run: Promise | undefined;
+ run = (async () => {
+ try {
+ const next = await fetchPendingAction(id, undefined, detailConversationId(entry));
+ if (!isCurrent()) return null;
+ remember(next, { authoritative: true });
+ patchEntry(id, key, (latest) => {
+ const changes: Partial = { denied: false };
+ if (refreshOptions.clearNotice) {
+ if (
+ latest.action.viewer_is_owner !== false &&
+ reviewedVersion &&
+ latest.action.version === reviewedVersion
+ ) {
+ changes.authAcknowledgedVersion = reviewedVersion;
+ changes.auth = null;
+ }
+ changes.notice = info(
+ 'Current server status loaded. Review the saved action before choosing Send or Cancel.',
+ );
+ }
+ return changes;
+ });
+ return next;
+ } catch (error) {
+ if (!isCurrent()) return null;
+ const status = statusOf(error);
+ const payload = m365ErrorPayload(error);
+ const changes: Partial = {
+ needsRefresh: true,
+ denied: status === 403 || status === 404,
+ notice: warn(status === 403 ? PERMISSION_MESSAGE : STATUS_UNKNOWN_MESSAGE),
+ };
+ if (status === 401 || payload.auth_required === true) {
+ changes.authAcknowledgedVersion = '';
+ changes.auth = payload;
+ }
+ patchEntry(id, key, changes);
+ return null;
+ } finally {
+ if (refreshes.get(id) === run) refreshes.delete(id);
+ patchEntry(id, key, { refreshing: false });
+ }
+ })();
+ refreshes.set(id, run);
+ return run;
+ };
+
+ const loadById: PendingActionsApi['loadById'] = async (rawId, loadOptions = {}) => {
+ const id = normalizePendingActionId(rawId);
+ if (!id) {
+ set((state) => ({ detail: { ...state.detail, [rawId]: { status: 'error', message: NOT_LOADED_MESSAGE } } }));
+ return null;
+ }
+ if (get().entries[id]) {
+ if (loadOptions.reference) {
+ const entry = get().entries[id];
+ patchEntry(id, entry.key, (latest) => ({ reference: mergeReference(latest.reference, loadOptions.reference) }));
+ }
+ return refresh(id);
+ }
+ const epoch = get().epoch;
+ set((state) => ({ detail: { ...state.detail, [id]: { status: 'loading', message: '' } } }));
+ try {
+ const action = await fetchPendingAction(id, undefined, scope === 'chat' ? get().conversationId : '');
+ if (get().epoch !== epoch) return null;
+ remember(action, { authoritative: true, reference: loadOptions.reference });
+ set((state) => {
+ const { [id]: _done, ...rest } = state.detail;
+ return { detail: rest };
+ });
+ return action;
+ } catch (error) {
+ if (get().epoch !== epoch) return null;
+ set((state) => ({
+ detail: {
+ ...state.detail,
+ [id]: { status: 'error', message: statusOf(error) === 403 ? PERMISSION_MESSAGE : NOT_LOADED_MESSAGE },
+ },
+ }));
+ return null;
+ }
+ };
+
+ const reviewFull: PendingActionsApi['reviewFull'] = async (id) => {
+ const entry = get().entries[id];
+ if (!entry || entry.busy || entry.reviewing) return;
+ const key = entry.key;
+ patchEntry(id, key, {
+ reviewing: true,
+ notice: info('Loading the complete saved content for review. This review does not send the action.'),
+ });
+ const action = await refresh(id);
+ if (!current(id, key)) return;
+ const complete =
+ Boolean(action) && !pendingActionNeedsFullReview(action as PendingAction) && typeof action?.summary?.body_preview === 'string';
+ patchEntry(id, key, (latest) => {
+ if (complete) {
+ return {
+ reviewing: false,
+ detailVersion: latest.action.version ?? '',
+ bodyOpen: true,
+ notice: info('Complete saved content loaded. Review the recipients and full content before sending.'),
+ };
+ }
+ if (action) {
+ return {
+ reviewing: false,
+ needsRefresh: true,
+ notice: warn('The complete saved content could not be verified. Review full details again before sending.'),
+ };
+ }
+ return { reviewing: false };
+ });
+ };
+
+ const submit: PendingActionsApi['submit'] = async (id, operation) => {
+ const entry = get().entries[id];
+ if (!entry) return;
+ const key = entry.key;
+ const allowed =
+ operation === 'cancel'
+ ? entry.action.can_cancel === true
+ : Boolean(pendingActionSendRoute(entry.action)) && !entry.auth && !entry.approvalRequired;
+ const version = entry.action.version;
+ if (!canChangeEntry(entry) || !allowed || !version) return;
+ patchEntry(id, key, {
+ busy: true,
+ notice: info(operation === 'cancel' ? 'Cancelling this saved action…' : 'Submitting this saved action…'),
+ });
+ sequences.set(id, (sequences.get(id) ?? 0) + 1);
+ try {
+ await refreshes.get(id);
+ const next = await mutatePendingAction(id, operation, version);
+ if (!current(id, key)) return;
+ remember(next, { authoritative: true });
+ patchEntry(id, key, {
+ auth: null,
+ denied: false,
+ notice: info(
+ operation === 'cancel'
+ ? 'Cancellation checked. The server status below is authoritative; this does not recall an already sent item.'
+ : 'Send request checked. Review the server status and delivery note below.',
+ ),
+ });
+ } catch (error) {
+ if (!current(id, key)) return;
+ const payload = m365ErrorPayload(error);
+ const status = statusOf(error);
+ const returned = isPendingAction(payload.pending_action) && payload.pending_action.id === id;
+ if (returned) remember(payload.pending_action as PendingAction, { authoritative: true });
+ if (payload.approval_required === true) {
+ patchEntry(id, key, {
+ approvalRequired: payload,
+ notice: warn(
+ 'Review the saved Microsoft 365 sharing decision before sending. Approving it does not send this action; return here and select Send again.',
+ ),
+ });
+ } else if (status === 401 || payload.auth_required === true) {
+ patchEntry(id, key, {
+ authAcknowledgedVersion: '',
+ auth: payload,
+ notice: warn(
+ 'Microsoft 365 sign-in is required. Reconnect, review this same saved action, then select Send again. Signing in does not send it.',
+ ),
+ });
+ } else if (status === 403) {
+ patchEntry(id, key, {
+ denied: true,
+ notice: warn('You do not have permission to change this action. Refresh status to check your current access.'),
+ });
+ } else if (status === 409) {
+ patchEntry(id, key, {
+ notice: warn(
+ 'This action changed or is already being processed. Review its current status and details before making another choice.',
+ ),
+ });
+ if (!returned) {
+ patchEntry(id, key, { needsRefresh: true });
+ await refresh(id);
+ }
+ } else {
+ patchEntry(id, key, {
+ needsRefresh: true,
+ notice: warn(
+ 'The response was not confirmed. Checking the current server status; the send will not be retried automatically.',
+ ),
+ });
+ const refreshed = await refresh(id);
+ if (refreshed) {
+ patchEntry(id, key, {
+ notice: warn(
+ 'The response was not confirmed. Current server status has been refreshed. Review it before making another choice; no send was retried automatically.',
+ ),
+ });
+ }
+ }
+ } finally {
+ patchEntry(id, key, { busy: false });
+ }
+ };
+
+ const reconnect: PendingActionsApi['reconnect'] = async (id) => {
+ const entry = get().entries[id];
+ if (!entry || entry.busy) return;
+ const key = entry.key;
+ const reviewedVersion = entry.action.version ?? '';
+ patchEntry(id, key, {
+ busy: true,
+ notice: info('Opening sign-in for this saved action. No chat request will be resumed and nothing will be sent.'),
+ });
+ try {
+ const fromPayload = m365Sources(entry.auth?.sources);
+ await connectMicrosoft365(
+ fromPayload.length ? fromPayload : [entry.action.graph_resource_type === 'calendar' ? 'calendar' : 'email'],
+ );
+ if (!current(id, key)) return;
+ await refresh(id, { clearNotice: true, acknowledgeAuthVersion: reviewedVersion });
+ } catch (error) {
+ patchEntry(id, key, { notice: warn(errorMessage(error, 'Sign-in was not confirmed. No action was sent.')) });
+ } finally {
+ patchEntry(id, key, { busy: false });
+ }
+ };
+
+ /* The conversation's cards ---------------------------------------------- */
+
+ const loadReference = (id: string, reference: PendingActionReference): Promise => {
+ const pending = referenceLoads.get(id);
+ if (pending) return pending;
+ const { conversationId, epoch } = get();
+ let run: Promise | undefined;
+ run = (async () => {
+ try {
+ const action = await fetchPendingAction(id, undefined, conversationId);
+ if (get().epoch !== epoch) return;
+ referenceFailures.delete(id);
+ remember(action, { authoritative: true, reference });
+ clearReferenceError();
+ } catch {
+ if (get().epoch !== epoch) return;
+ referenceFailures.set(id, reference);
+ set({ referenceError: REFERENCE_FAILED_MESSAGE });
+ } finally {
+ if (referenceLoads.get(id) === run) referenceLoads.delete(id);
+ if (get().referenceLoading && referenceLoads.size === 0) set({ referenceLoading: false });
+ }
+ })();
+ referenceLoads.set(id, run);
+ if (!get().referenceLoading) set({ referenceLoading: true });
+ return run;
+ };
+
+ const trackMessage: PendingActionsApi['trackMessage'] = (message, trackOptions = {}) => {
+ if (!message || typeof message !== 'object') return;
+ const history = trackOptions.history === true;
+ const reference = referenceForMessage(message);
+ for (const action of pendingActionsFromPayload({
+ m365_pending_actions: (message as { m365_pending_actions?: unknown }).m365_pending_actions,
+ })) {
+ add(action, reference, { history });
+ }
+ for (const id of referencedActionIds(message)) {
+ const entry = get().entries[id];
+ if (entry) {
+ patchEntry(id, entry.key, (latest) => ({
+ reference: mergeReference(latest.reference, reference),
+ needsRefresh: true,
+ }));
+ if (!pendingActionNeedsFullReview(entry.action)) void refresh(id);
+ } else {
+ void loadReference(id, reference);
+ }
+ }
+ };
+
+ const loadList: PendingActionsApi['loadList'] = (loadOptions = {}) => {
+ const { conversationId, epoch } = get();
+ if (!conversationId) return Promise.resolve();
+ if (listPromise) return listPromise;
+ const more = loadOptions.more === true;
+ const token = more ? get().continuationToken : '';
+ if (more && !token) return Promise.resolve();
+ const controller = new AbortController();
+ listController = controller;
+ set({ listStatus: more ? get().listStatus : 'loading', loadingMore: more, listError: '' });
+ let run: Promise | undefined;
+ run = (async () => {
+ try {
+ await Promise.all([...referenceFailures].map(([id, reference]) => loadReference(id, reference)));
+ const page = await fetchConversationPendingActions(conversationId, token, controller.signal);
+ if (get().epoch !== epoch) return;
+ for (const action of page.items) add(action, {}, { authoritative: true });
+ set({
+ continuationToken: page.continuationToken,
+ listStatus: 'loaded',
+ listError: '',
+ loadingMore: false,
+ referenceError: referenceFailures.size ? REFERENCE_FAILED_MESSAGE : '',
+ });
+ } catch (error) {
+ if (controller.signal.aborted || get().epoch !== epoch) return;
+ const denied = statusOf(error) === 403;
+ set((state) => ({
+ entries: Object.fromEntries(
+ Object.entries(state.entries).map(([id, entry]) => [id, { ...entry, needsRefresh: true, denied }]),
+ ),
+ listStatus: 'error',
+ listError: denied ? LIST_PERMISSION_MESSAGE : LIST_FAILED_MESSAGE,
+ loadingMore: false,
+ }));
+ } finally {
+ if (listPromise === run) listPromise = null;
+ if (listController === controller) listController = null;
+ }
+ })();
+ listPromise = run;
+ return run;
+ };
+
+ return {
+ scope,
+ conversationId: '',
+ epoch: 0,
+ entries: {},
+ detail: {},
+ listStatus: 'idle',
+ listError: '',
+ referenceError: '',
+ referenceLoading: false,
+ continuationToken: '',
+ loadingMore: false,
+ liveStream: null,
+ focusRequest: null,
+
+ remember,
+ refresh,
+ loadById,
+ reviewFull,
+ submit,
+ reconnect,
+ trackMessage,
+ loadList,
+
+ refreshList: async () => {
+ // A load that began before the announcement may not include what it announced.
+ if (listPromise) await listPromise;
+ await loadList();
+ },
+
+ setBodyOpen: (id, open) => {
+ const entry = get().entries[id];
+ if (!entry || entry.bodyOpen === open) return;
+ patchEntry(id, entry.key, { bodyOpen: open });
+ const latest = get().entries[id];
+ // Opening the body of a card whose version was not reviewed yet re-checks it first.
+ if (
+ open &&
+ latest &&
+ !latest.busy &&
+ !pendingActionNeedsFullReview(latest.action) &&
+ latest.detailVersion !== (latest.action.version ?? '')
+ ) {
+ patchEntry(id, latest.key, { detailVersion: latest.action.version ?? '' });
+ void refresh(id);
+ }
+ },
+
+ pollIfDue: (id) => {
+ const entry = get().entries[id];
+ if (entry && isPollDue(entry.action, entry.busy || entry.refreshing, entry.nextRefreshAt)) void refresh(id);
+ },
+
+ setConversation: (rawId) => {
+ const conversationId = typeof rawId === 'string' ? rawId.trim() : '';
+ if (conversationId === get().conversationId) return;
+ listController?.abort();
+ listController = null;
+ listPromise = null;
+ refreshes.clear();
+ sequences.clear();
+ referenceLoads.clear();
+ referenceFailures.clear();
+ trackedMessages.clear();
+ set((state) => ({
+ conversationId,
+ epoch: state.epoch + 1,
+ entries: {},
+ detail: {},
+ listStatus: 'idle',
+ listError: '',
+ referenceError: '',
+ referenceLoading: false,
+ continuationToken: '',
+ loadingMore: false,
+ liveStream: null,
+ // A deep link asks for its card before the conversation has finished switching.
+ focusRequest: state.focusRequest?.conversationId === conversationId ? state.focusRequest : null,
+ }));
+ },
+
+ ingestMessages: (messages) => {
+ for (const message of messages) {
+ const signature = messageTrackKey(message);
+ if (!signature) continue;
+ const messageId = referenceForMessage(message).messageId;
+ if (trackedMessages.get(messageId) === signature) continue;
+ trackedMessages.set(messageId, signature);
+ trackMessage(message, { history: true });
+ }
+ },
+
+ handleStreamPayload: (payload, frameOptions = {}) => {
+ const state = get();
+ if (!state.conversationId) return;
+ if (frameOptions.conversationId && frameOptions.conversationId !== state.conversationId) return;
+ const reference = referenceForStreamFrame(payload, frameOptions);
+ for (const action of pendingActionsFromPayload(payload)) add(action, reference);
+ const failure = pendingActionsErrorFromPayload(payload);
+ if (failure) {
+ set({ referenceError: failure });
+ void loadList();
+ }
+ },
+
+ setLiveStream: (stream) => {
+ const previous = get().liveStream;
+ if (!stream) {
+ if (previous) set({ liveStream: null });
+ return;
+ }
+ if (previous && previous.conversationId === stream.conversationId && previous.userMessageId === stream.userMessageId) return;
+ const renamed = previous?.conversationId === stream.conversationId ? previous.userMessageId : '';
+ set((state) => {
+ if (!renamed || renamed === stream.userMessageId) return { liveStream: stream };
+ // The temporary id the user turn had while it streamed is replaced by its saved id.
+ const entries = Object.fromEntries(
+ Object.entries(state.entries).map(([id, entry]) => [
+ id,
+ entry.reference.userMessageId === renamed || entry.reference.fallbackMessageId === renamed
+ ? {
+ ...entry,
+ reference: {
+ ...entry.reference,
+ userMessageId:
+ entry.reference.userMessageId === renamed ? stream.userMessageId : entry.reference.userMessageId,
+ fallbackMessageId:
+ entry.reference.fallbackMessageId === renamed
+ ? stream.userMessageId
+ : entry.reference.fallbackMessageId,
+ },
+ }
+ : entry,
+ ]),
+ );
+ return { liveStream: stream, entries };
+ });
+ },
+
+ requestFocus: (id, conversationId) => {
+ const safeId = normalizePendingActionId(id);
+ if (!safeId) return;
+ set({ focusRequest: { id: safeId, conversationId: conversationId.trim(), key: ++focusCounter } });
+ },
+
+ clearFocus: () => {
+ if (get().focusRequest) set({ focusRequest: null });
+ },
+
+ reset: () => {
+ listController?.abort();
+ listController = null;
+ listPromise = null;
+ refreshes.clear();
+ sequences.clear();
+ referenceLoads.clear();
+ referenceFailures.clear();
+ trackedMessages.clear();
+ set((state) => ({
+ epoch: state.epoch + 1,
+ conversationId: '',
+ entries: {},
+ detail: {},
+ listStatus: 'idle',
+ listError: '',
+ referenceError: '',
+ referenceLoading: false,
+ continuationToken: '',
+ loadingMore: false,
+ liveStream: null,
+ focusRequest: null,
+ }));
+ },
+ };
+ });
+}
+
+/* React access ---------------------------------------------------------------- */
+
+/** The store the chat view and its stream handlers share. */
+export const chatPendingActionsStore: StoreApi = createPendingActionsStore({ scope: 'chat' });
+
+const PendingActionsContext = createContext | null>(null);
+
+export function PendingActionsProvider({ store, children }: { store: StoreApi; children?: ReactNode }) {
+ return createElement(PendingActionsContext.Provider, { value: store }, children);
+}
+
+/** The store of the nearest provider, or the chat store when there is none. */
+export function usePendingActionsStoreApi(): StoreApi {
+ return useContext(PendingActionsContext) ?? chatPendingActionsStore;
+}
+
+/** Selectors must return a stable reference or a primitive; derive collections with useMemo. */
+export function usePendingActions(selector: (state: PendingActionsStore) => T): T {
+ return useStore(usePendingActionsStoreApi(), selector);
+}
diff --git a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md
index f120e89f8..e8c23f647 100644
--- a/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md
+++ b/docs/explanation/features/CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md
@@ -1637,7 +1637,10 @@ tracker, running tag and posted-message footers. The last bullet of the **V2** l
the notice's `workflow_scope` and `workflow_group_id` metadata: personal runs belong under `/workspace/workflows`,
group runs under the group's workflows page, and an unknown scope keeps the classic link. Once it returns a path,
N2's alert card shows **Open run** instead of **Open workflow**, so the run link has to lead somewhere the
- workflow link doesn't.
+ workflow link doesn't. Since 0.261.307 V2 chat renders pending actions and a chat link with `m365_pending_action`
+ opens the V2 conversation (see [V2 Microsoft 365 Pending-Action Cards](V2_M365_PENDING_ACTION_CARDS.md)); a
+ notice with `m365_pending_action_id` that links to `/workflow-activity` still stays classic, because V2's run
+ page doesn't draw the action.
62. **Result answers outside the message list.** Answers built from stored results (saved analysis or workflow
results) are masked when messages are read, but some reads skip that sanitizer: collaboration copies and their
`last_message_preview`, the collaboration metadata route (pending invitees included), summaries, reply
diff --git a/docs/explanation/features/MSGRAPH_PENDING_ACTIONS.md b/docs/explanation/features/MSGRAPH_PENDING_ACTIONS.md
index e329007ac..3c4897c77 100644
--- a/docs/explanation/features/MSGRAPH_PENDING_ACTIONS.md
+++ b/docs/explanation/features/MSGRAPH_PENDING_ACTIONS.md
@@ -23,6 +23,7 @@ The Microsoft Graph action configuration nests delivery options directly under t
- `semantic_kernel_plugins/msgraph_plugin.py` creates pending actions for manual or delayed mail/calendar operations and returns sanitized `pending_action` metadata in plugin results.
- `functions_workflow_activity.py` merges pending Microsoft Graph actions into workflow activity snapshots.
- `chat-messages.js` and `workflow-activity.js` render send/cancel controls and countdowns from sanitized pending action metadata.
+- In the React V2 interface the shared `PendingActionCard` draws the same controls in chat and on the Approvals page; see [V2 Microsoft 365 Pending-Action Cards](V2_M365_PENDING_ACTION_CARDS.md).
- Consent or interactive-auth errors render a friendly Microsoft 365 grant-access card for Outlook email, Calendar, OneDrive, and SharePoint access. The card opens the generated Microsoft identity prompt in a popup, then offers a test-access button that verifies silent access after consent and clears the prompt.
### API Endpoints
diff --git a/docs/explanation/features/V2_M365_PENDING_ACTION_CARDS.md b/docs/explanation/features/V2_M365_PENDING_ACTION_CARDS.md
new file mode 100644
index 000000000..0f7f14faf
--- /dev/null
+++ b/docs/explanation/features/V2_M365_PENDING_ACTION_CARDS.md
@@ -0,0 +1,114 @@
+# V2 Microsoft 365 Pending-Action Cards
+
+## Overview
+
+Implemented in version: **0.261.307**
+
+Tracking issue: [#1722](https://github.com/microsoft/simplechat/issues/1722), gap 2 (Chat, "M365 pending-action card").
+
+An agent that prepares a Microsoft 365 email or calendar invitation for review saves it as a pending action. The action waits until its owner sends or cancels it, and a notification tells the owner it is waiting. Only the classic chat page drew that card, so every such notification opened classic chat.
+
+The React V2 chat now draws the card itself, with the same controls and safeguards, and a notification about a saved action opens the V2 conversation, scrolls to the card, and highlights it. The link no longer depends on the classic interface being reachable, which matters wherever people are kept in V2.
+
+Dependencies:
+
+* [Microsoft Graph Pending Actions](MSGRAPH_PENDING_ACTIONS.md): the backend this reads and writes. No route, schema, or setting changed.
+* [V2 Terms of Use and Approval Requests](V2_TERMS_AND_APPROVALS.md): the Approvals page uses the same card.
+* [V2 Notification Bell and Desktop Notifications](V2_NOTIFICATIONS_BELL.md): the notices that link to the card.
+* [React V2 UI](REACT_V2_UI.md): the shell and chat page the card lives in.
+
+## Technical Specifications
+
+### Where a card appears
+
+A card sits under the reply that saved it, as it does in classic chat. Placement is worked out once for the whole thread, in this order:
+
+1. The message the action names, when that message is on screen.
+2. While the reply that saved it is still streaming, under the streaming bubble.
+3. Otherwise the user turn that caused it, when that turn is on screen. This is also where a card sits if its reply was never saved.
+4. The last message of the same request, when the action carries a request id.
+5. Otherwise the conversation-level section, **Microsoft 365 outgoing actions for this conversation**, so a pending send is never hidden.
+
+That section also carries the things that belong to the conversation rather than to one reply: a failed or partial read of the list ("Outgoing actions could not be loaded. Refresh to recover saved actions; this is not an empty inbox."), **Load more outgoing actions** when the list has another page, and **Refresh outgoing actions**. Image, file, and content-safety messages never carry a card.
+
+### Where the cards come from
+
+* **Opening a conversation.** The conversation's saved actions are listed with `GET /api/msgraph/pending-actions?conversation_id=&limit=30`, following the continuation token for more. The list is read again when the browser window regains focus or comes back online.
+* **History.** Messages loaded from history carry their cards and the ids of any that were not loaded with them. Ids without a card are fetched one at a time with `GET /api/msgraph/pending-actions/?conversation_id=`.
+* **A live reply.** The `m365_pending_action` stream frame puts a card on screen while the reply is still streaming. The finishing frame can carry the final cards, which then sit under the saved reply, or `m365_pending_actions_error` when the server could not attach them, in which case the list is read again.
+* **After a reply ends.** The conversation's list is read again when a reply finishes or fails, when the reader stops it (once the stop request has been sent, so an action the server saves after that shows on the next read, such as window focus or **Refresh outgoing actions**), and when a reply that was still running is picked up again. An action that the live frames did not deliver, for example after a dropped connection, is on screen before anyone repeats the request.
+* **A shared conversation.** Other participants are told which action ids a reply saved, and read the list again. When the server could not list them, the thread says "Microsoft 365 actions were saved, but their cards could not be loaded. Reload the conversation to recover them. Do not repeat the request."
+
+A card taken from message history is checked against the server before Send is available, while one from a live frame is as fresh as the server can make it. Either way, a card ignores a snapshot older than the one it holds, a version it has already moved past, and one that would reopen a finished action, and a response that arrives after the reader has left the conversation is dropped. A scheduled card whose send time has passed, and a card whose send is in progress, ask the server again about every 15 seconds.
+
+### The card
+
+The same `PendingActionCard` is used inline in chat and in the Approvals detail pane, so a card behaves the same wherever it is drawn. It shows recipients, times, location, a body preview that is always plain text, and a status. Its controls match the [classic card](../../reference/chat-controls.md#microsoft-365-outgoing-action-cards):
+
+* **Send** or **Send now** (for a delayed action) submits the version of the action the reader reviewed, so the server can refuse it if the action changed in the meantime.
+* **Cancel** stops an unclaimed delivery.
+* **Review full message** or **Review full invitation** loads the complete content when the preview was shortened. Send stays hidden until it has loaded, and reviewing never sends.
+* **Reconnect Microsoft 365** renews the Microsoft 365 sign-in for the saved action. It does not resume the chat request or send anything; select Send again afterwards. A saved action that belongs to a workflow links to the workflow's account in Settings instead.
+* **Review sharing decision** opens the data-access approval that a send asked for. Approving that decision does not send the action.
+* **Refresh status** asks the server for the current state. It never sends.
+* **Open in Microsoft 365** opens the web link, only when it is an `https` link without credentials.
+
+Only the owner gets Send and Cancel; both are hidden for anyone else, who sees a read-only note instead. The countdown on a delayed action is informational: delivery is by the server's own timer, and loading an overdue card never submits a send.
+
+### Deep links from a notification
+
+A notification link that names a saved action, such as `/chats?conversationId=&m365_pending_action=`, now opens V2 chat instead of classic chat.
+
+* From another page, the V2 router opens `/chat?conversationId=&m365_pending_action=`. On the chat page, a notice for another conversation opens it in place.
+* The chat page reads the action id once and removes it from the address, so the final address is `/chat?conversationId=`. A reload or a shared link does not scroll again.
+* Once the conversation's messages and saved actions have loaded and the card is drawn, the thread scrolls to it, rings it for about two and a half seconds, and moves keyboard focus to it. Reduced-motion preferences turn the smooth scroll into a jump. Opening a card never sends it.
+* A card that the conversation's list does not show is fetched by id. Only if that also fails does the thread say "That Microsoft 365 action is not available in this conversation. It may have been removed, or you may not have access to it." and leave the conversation open.
+* An action id that could not be a safe path segment (empty, over 200 characters, `.`, `..`, or containing a slash, backslash, `?`, `#`, or a control character) is dropped, and the conversation still opens.
+
+The Approvals detail pane's **Open conversation** link uses the same address.
+
+Notices about a Microsoft 365 action inside a workflow run still open the classic workflow-activity page, which draws the action beside the run. That is unchanged and out of scope here.
+
+### File structure
+
+Paths are relative to `application/v2_ui/src`.
+
+* `lib/m365PendingActions.ts`: pure rules for which snapshot to trust, where a card belongs, and what a stream frame or history message carries.
+* `stores/m365PendingActionsStore.ts`: one store factory. Chat uses a shared instance, so the chat stream handlers, which run outside React, can hand it cards; every Approvals detail builds its own for the one action it shows.
+* `components/approvals/PendingActionCard.tsx`: the shared card. `PendingActionsPanel.tsx` is now a thin wrapper that gives it a store.
+* `components/chat/PendingActionSlots.tsx`: placement for the whole thread, the inline and streaming slots, the conversation-level section, list loading, and the deep-link focus.
+* `components/chat/MessageList.tsx`: mounts the placement, the section, and a slot under each bubble.
+* `lib/approvalsApi.ts`: `fetchConversationPendingActions` is new, `fetchPendingAction` takes a conversation id, and `PendingAction` carries the extra fields the chat endpoints return.
+* `lib/sse.ts`, `lib/types.ts`, `lib/collaborationEvents.ts`, `stores/chatStore.ts`: the stream and shared-conversation wiring, and keeping the store pointed at the open conversation.
+* `lib/conversationUrl.ts`, `lib/notificationLinks.ts`, `lib/notificationNavigation.ts`, `pages/ChatPage.tsx`: the `m365_pending_action` parameter, routing, and the one-shot focus.
+* `scripts/check_xss_sinks.py`: `chatHrefForPendingAction` is listed as a reviewed same-origin link builder.
+
+## Usage Instructions
+
+Nothing new to enable. Cards appear whenever an agent uses a Microsoft Graph action in manual or delayed delivery mode, as described in [Microsoft Graph Pending Actions](MSGRAPH_PENDING_ACTIONS.md).
+
+* Review the card under the reply, open **Review message body** to read the body, then choose **Send** or **Cancel**.
+* Choose a notification in the bell to land on the card in its conversation. The card is outlined for a moment so you can find it in a long thread.
+* If a card is missing from a thread, use **Refresh outgoing actions** at the foot of the conversation-level section.
+
+## Testing and Validation
+
+Functional tests:
+
+* `functional_tests/test_v2_m365_pending_actions.py`: the wiring across the chat page, stores, stream, collaboration events, routing, and shared card, and a run of the three Node tests below.
+* `functional_tests/test_v2_m365_pending_actions_logic.mjs`: snapshot trust, placement, stream and history references, and countdown rules.
+* `functional_tests/test_v2_m365_pending_actions_store.mjs`: the store's state machine, including late responses, version safety, polling, and focus.
+* `functional_tests/test_v2_m365_pending_actions_stream.mjs`: stream frames and shared-conversation events reaching the store.
+* `functional_tests/test_v2_workflow_run_link_routing.mjs`, `functional_tests/test_v2_notifications_bell.py`, `functional_tests/test_v2_user_settings_memory_m365.py`: updated for the new routing and files.
+
+UI tests:
+
+* `ui_tests/test_v2_m365_pending_action_cards.py`: the real message list with inline, streaming, and conversation-level cards, Send and Cancel, and focus.
+* `ui_tests/test_v2_notifications_bell.py`: a notice for a saved action opens its card in V2 chat from another page, from the chat page, for an action the list does not show, and for one that is gone.
+* `ui_tests/test_v2_approvals_and_terms_pages.py` and `ui_tests/test_v2_approvals_dashboard.py`: the Approvals page, which keeps its test ids on the shared card.
+
+## Known Limitations
+
+* A chat request that pauses for Microsoft 365 sign-in or a sharing approval (`m365_sign_in_required`, `m365_approval_required`) still shows as an ordinary stream error in V2 chat, as it did before. This change adds no pause interface for it. The list is read again when the request ends, so any action the request had already saved still gets its card. V2's orchestration notices for Microsoft 365 (`OrchestrationM365Notice.tsx`) are separate and unchanged.
+* Notices about an action inside a workflow run open the classic workflow-activity page.
+* The backend is unchanged: delayed auto-send still uses the in-process timer described in [Microsoft Graph Pending Actions](MSGRAPH_PENDING_ACTIONS.md).
diff --git a/docs/explanation/features/V2_NOTIFICATIONS_BELL.md b/docs/explanation/features/V2_NOTIFICATIONS_BELL.md
index 61cf30098..8790031ec 100644
--- a/docs/explanation/features/V2_NOTIFICATIONS_BELL.md
+++ b/docs/explanation/features/V2_NOTIFICATIONS_BELL.md
@@ -131,7 +131,7 @@ in classic rather than leaving the notice with nowhere to go.
| Link | Opens |
|---|---|
| `/chats`, `/chat` or `/v2/chat` with `conversationId` or `conversation_id` | The conversation in V2 chat |
-| The same with `m365_pending_action` | Classic chat, the only page that shows the pending-action card |
+| The same with `m365_pending_action` | The V2 conversation, scrolled to that saved action's card and highlighted |
| `/approvals` | Classic Approvals |
| `/workflow-activity` | Classic workflow activity (see the Phase 6b seams below) |
| `/workspace` | Your V2 document list; there is no per-document link yet |
@@ -185,8 +185,10 @@ Phase 6b-2 (0.261.251) filled in all three; see
`workflow_chat_delivery`) opens its run when its metadata names the workspace,
workflow and run. Any other notice without a link gets none.
- A Microsoft 365 notice, one with `m365_pending_action_id` in its metadata or link
- context, always stays classic, which is the only interface that renders the
- pending action.
+ context, never opens a V2 run page. The classic workflow-activity page draws the
+ saved action beside the run, and V2's run page doesn't. A notice about an action
+ saved in a conversation opens that conversation in V2 instead; see
+ [V2 Microsoft 365 Pending-Action Cards](V2_M365_PENDING_ACTION_CARDS.md).
- The workspace is never read from `metadata.group_id`, which classic treats as the
group to make active.
- **Delivered results.** When 6b-1 posts a result back into a chat, V2's app-shell
@@ -291,8 +293,10 @@ conversations keep their own read tracking, so neither is affected.
## Known limitations
-- Microsoft 365 approvals, pending Microsoft 365 actions and workflow activity open
- in classic until V2 has those pages.
+- Microsoft 365 approvals and workflow activity open in classic until V2 has those
+ pages. That includes a notice about a Microsoft 365 action inside a workflow run.
+ A notice about an action saved in a conversation opens that conversation in V2
+ with the card brought into view.
- A personal document notice opens your document list, not the document.
- The count is polled. While the tab is visible and nothing is changing, a new
notice can take up to five minutes to appear; returning to the tab reads it at
@@ -330,6 +334,7 @@ data.
- [Desktop Conversation Notifications](DESKTOP_CONVERSATION_NOTIFICATIONS.md)
- [V2 Workflow Alert Notices](V2_WORKFLOW_ALERT_NOTICES.md)
+- [V2 Microsoft 365 Pending-Action Cards](V2_M365_PENDING_ACTION_CARDS.md)
- [Chat Orchestration Workflows Roadmap](CHAT_ORCHESTRATION_WORKFLOWS_ROADMAP.md), Track N
- [Manage notifications]({{ '/guides/manage-notifications/' | relative_url }})
- [Chat settings]({{ '/admin/chat/' | relative_url }})
diff --git a/docs/explanation/features/V2_TERMS_AND_APPROVALS.md b/docs/explanation/features/V2_TERMS_AND_APPROVALS.md
index c5745fa2a..7e4dd29d8 100644
--- a/docs/explanation/features/V2_TERMS_AND_APPROVALS.md
+++ b/docs/explanation/features/V2_TERMS_AND_APPROVALS.md
@@ -34,7 +34,7 @@ Dependencies: the existing Terms of Use feature ([TERMS_OF_USE.md](TERMS_OF_USE.
* `ApprovalParts.tsx`: shared list/detail split, toolbar, rows, pager, and notices.
* `GenericApprovalsPanel.tsx`: All, Group, Microsoft 365 and Content screening categories from one `/api/approvals` fetch per status, filtered on the client.
* `M365ApprovalDetail.tsx`: Microsoft 365 approval decisions, with the CSRF retry the classic page uses.
- * `PendingActionsPanel.tsx`: `/api/msgraph/pending-actions` send, send now, and cancel.
+ * `PendingActionsPanel.tsx`: a thin wrapper that gives the shared `PendingActionCard` a store for the one action the detail pane shows. The card sends, sends now, cancels, and reviews through `/api/msgraph/pending-actions`, and is the same card V2 chat draws (see [V2 Microsoft 365 Pending-Action Cards](V2_M365_PENDING_ACTION_CARDS.md)).
* `PausedRequestsPanel.tsx`: `/api/m365/requests` with continuation, resume, and connect-and-resume.
* `AgentTemplatesPanel.tsx`: `/api/admin/agent-templates` approve, reject (reason required), and delete. Admin only.
* `lib/approvalsApi.ts`: every approvals call and request-type label.
diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md
index 0e9fa290e..99330787f 100644
--- a/docs/reference/chat-controls.md
+++ b/docs/reference/chat-controls.md
@@ -4,7 +4,7 @@ title: "Chat interface controls"
description: "Reference for every documented control in the SimpleChat chat interface."
section: "Reference"
audience: user
-version: "0.261.305"
+version: "0.261.307"
---
## How to use this reference
@@ -138,6 +138,14 @@ For email, Send submits the reviewed content and leaves the original Outlook
draft. **Do not send the retained draft again.** See
[Microsoft 365 Email]({{ '/reference/actions/m365-email/' | relative_url }}).
+The React V2 chat draws the same card with the same controls, under the reply that
+saved it, including while that reply is still streaming. A saved action whose reply
+is not on screen appears in a **Microsoft 365 outgoing actions for this
+conversation** section at the foot of the thread, which also offers **Load more
+outgoing actions** and **Refresh outgoing actions**. A notification about a saved
+action opens its conversation and scrolls to the card. See
+[V2 Microsoft 365 pending-action cards]({{ '/explanation/features/V2_M365_PENDING_ACTION_CARDS/' | relative_url }}).
+
## Microsoft 365 citations and Open online
Implemented in version: **0.261.303** (`application/single_app/config.py`).
diff --git a/functional_tests/test_v2_m365_pending_actions.py b/functional_tests/test_v2_m365_pending_actions.py
new file mode 100644
index 000000000..63784ba4f
--- /dev/null
+++ b/functional_tests/test_v2_m365_pending_actions.py
@@ -0,0 +1,551 @@
+#!/usr/bin/env python3
+# test_v2_m365_pending_actions.py
+"""
+Functional test for Microsoft 365 pending-action cards in the V2 chat.
+
+Version: 0.261.307
+Implemented in: 0.261.307
+
+A Microsoft 365 action the assistant saves for review (an email or a calendar change waiting
+behind Send, Send now and Cancel) used to be drawn only by the classic chat page. V2 sent every
+notice about one to `/chats`, which is a dead end once the administrator turns on New UI only:
+a person who is not an administrator cannot open a classic page at all.
+
+The cards are now drawn by the V2 chat itself:
+
+ - one shared `PendingActionCard`, used by the Approvals page and by the chat, so the two can
+ never disagree about what a saved action is allowed to do;
+ - one `m365PendingActionsStore`, which owns every card's state (the saved action, the versions
+ it has already moved past, what is being sent) and is the only thing that talks to the server;
+ - placement that matches classic: under the reply that saved the action (live while the reply
+ streams), and in a conversation section for actions no reply on screen accounts for;
+ - a read of the conversation's list whenever a reply ends (finishes, fails, is stopped, or was
+ picked up again), because a reply can save an action its own stream never described; and
+ - notices that open the V2 chat with the card named, so the thread scrolls to it and
+ highlights it once.
+
+This test ensures those pieces stay wired together and runs the Node runtime tests that execute
+the real modules: the pure rules, the store, and the live paths (the stream reader, a real send,
+shared-conversation events and the notification hand-off). No backend change was needed, and
+none is made.
+"""
+
+import importlib.util
+import re
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+V2_SRC = REPO_ROOT / "application" / "v2_ui" / "src"
+APP_DIR = REPO_ROOT / "application" / "single_app"
+
+sys.path.insert(0, str(REPO_ROOT / "functional_tests"))
+
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+
+IMPLEMENTED_IN = "0.261.307"
+
+HELPERS_MODULE = V2_SRC / "lib" / "m365PendingActions.ts"
+STORE_MODULE = V2_SRC / "stores" / "m365PendingActionsStore.ts"
+CARD_COMPONENT = V2_SRC / "components" / "approvals" / "PendingActionCard.tsx"
+PANEL_COMPONENT = V2_SRC / "components" / "approvals" / "PendingActionsPanel.tsx"
+SLOTS_COMPONENT = V2_SRC / "components" / "chat" / "PendingActionSlots.tsx"
+MESSAGE_LIST = V2_SRC / "components" / "chat" / "MessageList.tsx"
+CHAT_STORE = V2_SRC / "stores" / "chatStore.ts"
+CHAT_PAGE = V2_SRC / "pages" / "ChatPage.tsx"
+SSE_MODULE = V2_SRC / "lib" / "sse.ts"
+COLLABORATION_EVENTS = V2_SRC / "lib" / "collaborationEvents.ts"
+CONVERSATION_URL = V2_SRC / "lib" / "conversationUrl.ts"
+NOTIFICATION_LINKS = V2_SRC / "lib" / "notificationLinks.ts"
+NOTIFICATION_NAVIGATION = V2_SRC / "lib" / "notificationNavigation.ts"
+APPROVALS_API = V2_SRC / "lib" / "approvalsApi.ts"
+M365_RUNTIME = APP_DIR / "functions_m365_runtime.py"
+XSS_CHECKER_FILE = REPO_ROOT / "scripts" / "check_xss_sinks.py"
+
+LOGIC_TEST = REPO_ROOT / "functional_tests" / "test_v2_m365_pending_actions_logic.mjs"
+STORE_TEST = REPO_ROOT / "functional_tests" / "test_v2_m365_pending_actions_store.mjs"
+STREAM_TEST = REPO_ROOT / "functional_tests" / "test_v2_m365_pending_actions_stream.mjs"
+NODE_MODULES = REPO_ROOT / "application" / "v2_ui" / "node_modules"
+
+NEW_FILES = (
+ HELPERS_MODULE,
+ STORE_MODULE,
+ CARD_COMPONENT,
+ SLOTS_COMPONENT,
+ LOGIC_TEST,
+ STORE_TEST,
+ STREAM_TEST,
+)
+
+# What the Approvals page's tests and the browser tests look for. The card moved out of the
+# panel, so these have to travel with it.
+CARD_TEST_IDS = (
+ "v2-pending-action-card",
+ "v2-pending-action-status",
+ "v2-pending-action-body",
+ "v2-pending-action-countdown",
+ "v2-pending-action-controls",
+ "v2-pending-action-send",
+ "v2-pending-action-cancel",
+)
+
+PENDING_ACTION_PARAM = "m365_pending_action"
+
+
+def _read(path):
+ if not path.exists():
+ raise AssertionError(f"Expected file is missing: {path}")
+ return path.read_text(encoding="utf-8", errors="ignore")
+
+
+def _strip_comments(source):
+ """Source without block and line comments, so a comment explaining a rule cannot satisfy it."""
+ return re.sub(r"/\*[\s\S]*?\*/|(?" not in list_source:
+ raise AssertionError("A finished message does not offer its own cards a slot.")
+ print(" Every message offers its own cards a slot.")
+
+ # The module-level store is what the chat page uses; the Approvals page builds its own
+ # through the factory so the two never share a conversation filter.
+ if "export const chatPendingActionsStore" not in store_source:
+ raise AssertionError("The chat's pending-action store is not exported.")
+ if "export function createPendingActionsStore" not in store_source:
+ raise AssertionError("The store factory the Approvals page uses is not exported.")
+ print(" The chat and the Approvals page use separate stores.")
+
+ print("Placement test passed!")
+ return True
+
+
+def test_a_failed_reply_still_shows_the_action_it_saved():
+ """A run can fail after saving an action; hiding that card would leave it unsent and unseen."""
+ print("Testing the stream ordering...")
+ sse_source = _read(SSE_MODULE)
+ chat_store_source = _read(CHAT_STORE)
+ events_source = _read(COLLABORATION_EVENTS)
+
+ handler = "if (carriesPendingActions(event)) handlers.onM365PendingActions?.(event);"
+ error_check = "if (event.error || event.auth_required === true) {"
+ if handler not in sse_source or error_check not in sse_source:
+ raise AssertionError("sse.ts no longer has the pending-action report or the error check.")
+ if sse_source.index(handler) > sse_source.index(error_check):
+ raise AssertionError(
+ "sse.ts reports saved actions after the error check, so a reply that fails after "
+ "saving an action never shows it."
+ )
+ print(" Saved actions are reported before the error check.")
+
+ # A refused request (HTTP 500) is read through a different path from a stream frame.
+ refused = sse_source.index("if (carriesPendingActions(event)) handlers.onM365PendingActions?.(event);", sse_source.index(handler) + 1)
+ if "captureError(" not in sse_source[refused : refused + 400]:
+ raise AssertionError("A refused request no longer reports its actions before its error.")
+ print(" A refused request reports its actions before its error too.")
+
+ # Reported against the conversation on screen, not the frame's own id: a shared conversation's
+ # frames can carry the id of the hidden source conversation.
+ if chat_store_source.count("onM365PendingActions:") < 2:
+ raise AssertionError(
+ "chatStore.ts does not wire onM365PendingActions for both the reply stream and "
+ "the shared-conversation events."
+ )
+ if "handleStreamPayload(event, {" not in chat_store_source:
+ raise AssertionError("The reply stream does not hand saved actions to the store.")
+ print(" The reply stream and the shared-conversation events both reach the store.")
+
+ if "collaboration.m365.pending_action" not in events_source:
+ raise AssertionError("The collaboration events no longer carry pending-action announcements.")
+ print(" Shared conversations are told to read the list again.")
+
+ print("Stream ordering test passed!")
+ return True
+
+
+def _slice(source, start, end):
+ """Comment-free source from one anchor to the next, failing loudly when either has moved."""
+ begin = source.find(start)
+ if begin < 0:
+ raise AssertionError(f"chatStore.ts no longer contains {start!r}.")
+ finish = source.find(end, begin + len(start))
+ if finish < 0:
+ raise AssertionError(f"chatStore.ts no longer has {end!r} after {start!r}.")
+ return _strip_comments(source[begin:finish])
+
+
+def test_a_reply_that_ends_reads_the_saved_actions_again():
+ """A reply can save an action its own stream never described; only the list knows."""
+ print("Testing the read after a reply...")
+ source = _read(CHAT_STORE)
+
+ helper = _slice(source, "function refreshPendingActionsAfterReply(", "\n}\n")
+ if "pendingActions.conversationId !== conversationId" not in helper:
+ raise AssertionError(
+ "The read after a reply no longer checks the reader is still in that conversation, "
+ "so it would read the list of whatever thread is on screen."
+ )
+ if "pendingActions.refreshList()" not in helper:
+ raise AssertionError("The read after a reply no longer reads the conversation's list.")
+ print(" The read is skipped when the reader has moved on.")
+
+ # Each ending the classic client reads after: a reply that finishes or fails, one picked up
+ # again after the reader was away, and one the reader stops.
+ sent = _slice(source, "async function runChatStream(", "async function resumeChatStream(")
+ if "if (ownsController()) {" not in sent or "refreshPendingActionsAfterReply(conversationId)" not in sent:
+ raise AssertionError("A reply that ends does not read the saved actions again.")
+ if sent.index("if (ownsController()) {") > sent.index("refreshPendingActionsAfterReply("):
+ raise AssertionError("The read after a reply runs even when a newer send owns the screen.")
+ print(" A reply that finishes or fails reads the list again.")
+
+ resumed = _slice(source, "async function resumeChatStream(", "\n/**")
+ if "if (isCurrent()) {" not in resumed or "refreshPendingActionsAfterReply(conversationId)" not in resumed:
+ raise AssertionError("A reply picked up again does not read the saved actions when it ends.")
+ if resumed.index("if (isCurrent()) {") > resumed.index("refreshPendingActionsAfterReply("):
+ raise AssertionError("The read after a picked-up reply runs after the reader left it.")
+ print(" A reply picked up again reads the list when it ends.")
+
+ stopped = _slice(source, "stopStreaming: () => {", "setDrawerMode:")
+ if "const stoppedConversationId = streamingConversationId" not in stopped:
+ raise AssertionError("Stop no longer remembers which conversation it stopped.")
+ if stopped.index("stoppedConversationId = streamingConversationId") > stopped.index("detachActiveStream()"):
+ raise AssertionError(
+ "Stop reads the conversation after detaching, when detaching has already forgotten it."
+ )
+ if ".then(() => refreshPendingActionsAfterReply(stoppedConversationId))" not in stopped:
+ raise AssertionError("A reply the reader stops does not read the saved actions afterwards.")
+ print(" A stopped reply reads the list once the server has the stop request.")
+
+ print("Read after a reply test passed!")
+ return True
+
+
+def test_a_detached_reply_is_no_longer_a_live_turn():
+ """Left registered, the next send would take the stopped reply's turn for a renamed one."""
+ print("Testing that detaching clears the live turn...")
+ source = _read(CHAT_STORE)
+
+ detach = _slice(source, "function detachActiveStream(): void {", "\n}\n")
+ if "chatPendingActionsStore.getState().setLiveStream(null)" not in detach:
+ raise AssertionError(
+ "detachActiveStream no longer clears the pending-action store's live stream, so a "
+ "message sent after Stop pulls the stopped reply's saved actions onto the new turn."
+ )
+ print(" Stopping or leaving a reply clears the turn its saved actions were drawn under.")
+
+ print("Detached reply test passed!")
+ return True
+
+
+def test_notices_open_the_v2_chat_with_the_card_named():
+ """Notices that opened classic /chats are dead ends under New UI only."""
+ print("Testing notification routing...")
+ links_source = _read(NOTIFICATION_LINKS)
+ navigation_source = _read(NOTIFICATION_NAVIGATION)
+ url_source = _read(CONVERSATION_URL)
+ page_source = _read(CHAT_PAGE)
+
+ code = _strip_comments(links_source)
+ if f"searchParams.has('{PENDING_ACTION_PARAM}')" in code:
+ raise AssertionError(
+ "notificationLinks.ts still branches on the pending-action parameter, which is how "
+ "notices were sent to the classic chat page."
+ )
+ if re.search(r"classic\(\s*`/chats", code):
+ raise AssertionError("notificationLinks.ts still opens a classic /chats page.")
+ print(" No notice about a saved action is sent to the classic chat page.")
+
+ if "readPendingActionFocus(url.searchParams)" not in links_source:
+ raise AssertionError("The notice resolver does not read the saved action's id.")
+ if "pendingActionId" not in navigation_source:
+ raise AssertionError("The navigation does not carry the saved action to the chat.")
+ if "requestFocus(pendingActionId, conversationId)" not in navigation_source:
+ raise AssertionError("Following a notice on the chat page does not ask for the card.")
+ if "chatHrefForPendingAction(conversationId, pendingActionId)" not in navigation_source:
+ raise AssertionError("Following a notice from another page does not name the card.")
+ print(" The chat is opened with the card named, from the chat page or from any other.")
+
+ if f"M365_PENDING_ACTION_PARAM = '{PENDING_ACTION_PARAM}'" not in url_source:
+ raise AssertionError("The V2 address parameter is not named like the one the server writes.")
+ if "readPendingActionFocus" not in page_source:
+ raise AssertionError("The chat page does not read the card named in its address.")
+ print(" The chat page reads the card out of its address and the address is then cleaned up.")
+
+ print("Notification routing test passed!")
+ return True
+
+
+def test_the_server_writes_the_link_parameter_the_client_reads():
+ """The notice's link comes from the backend; the two ends have to agree on its name."""
+ print("Testing the link parameter...")
+ runtime_source = _read(M365_RUNTIME)
+ if f"'{PENDING_ACTION_PARAM}': action['id']" not in runtime_source:
+ raise AssertionError(
+ f"functions_m365_runtime.py no longer writes the {PENDING_ACTION_PARAM} link "
+ "parameter the V2 client reads."
+ )
+ if "/chats?" not in runtime_source:
+ raise AssertionError("The notice link no longer points at /chats, which V2 maps to its chat.")
+ print(f" The server writes {PENDING_ACTION_PARAM} and the V2 client reads the same name.")
+ print("Link parameter test passed!")
+ return True
+
+
+def test_the_conversation_can_be_listed_without_a_backend_change():
+ """The list the chat reads already existed; asking it about one conversation is a query."""
+ print("Testing the conversation list request...")
+ api_source = _read(APPROVALS_API)
+ if "export async function fetchConversationPendingActions" not in api_source:
+ raise AssertionError("There is no request for one conversation's saved actions.")
+ if "conversation_id=" not in api_source and "conversation_id" not in api_source:
+ raise AssertionError("The request does not scope the list to a conversation.")
+ if "Outgoing actions could not be verified." not in api_source:
+ raise AssertionError(
+ "A list the server could not verify is not reported, so an unverifiable list would "
+ "read as an empty one."
+ )
+ print(" The list is scoped to the conversation and an unverifiable list is an error.")
+ print("Conversation list test passed!")
+ return True
+
+
+def _load_xss_checker():
+ spec = importlib.util.spec_from_file_location("check_xss_sinks_for_m365_cards", XSS_CHECKER_FILE)
+ if spec is None or spec.loader is None:
+ raise AssertionError("Expected a module spec for check_xss_sinks.py")
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[spec.name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+def test_no_html_sink_or_remote_asset_was_introduced():
+ """Saved actions carry model-written text; none of it may be rendered as HTML."""
+ print("Testing rendering safety...")
+ remote_pattern = re.compile(r"https?://(?!localhost)[^\s'\"`)]+", re.I)
+
+ for path in (HELPERS_MODULE, STORE_MODULE, CARD_COMPONENT, SLOTS_COMPONENT, PANEL_COMPONENT):
+ source = _read(path)
+ for sink in ("dangerouslySetInnerHTML", "innerHTML", "outerHTML", "insertAdjacentHTML"):
+ if sink in source:
+ raise AssertionError(f"{path.relative_to(REPO_ROOT)} uses {sink}.")
+ for match in remote_pattern.finditer(source):
+ raise AssertionError(f"{path.relative_to(REPO_ROOT)} references a remote URL: {match.group(0)}")
+ print(" No HTML sink and no remote URL in the new modules.")
+
+ checker = _load_xss_checker()
+ if "chatHrefForPendingAction" not in checker.TS_SAME_ORIGIN_URL_BUILDERS:
+ raise AssertionError(
+ "chatHrefForPendingAction is not an approved same-origin URL builder, so the card's "
+ "Open conversation link fails the XSS sink check."
+ )
+ for path in (
+ HELPERS_MODULE,
+ STORE_MODULE,
+ CARD_COMPONENT,
+ PANEL_COMPONENT,
+ SLOTS_COMPONENT,
+ CONVERSATION_URL,
+ NOTIFICATION_LINKS,
+ NOTIFICATION_NAVIGATION,
+ SSE_MODULE,
+ COLLABORATION_EVENTS,
+ APPROVALS_API,
+ ):
+ issues = checker.inspect_file(path)
+ if issues:
+ raise AssertionError(
+ f"{path.relative_to(REPO_ROOT)} fails the XSS sink check: "
+ + "; ".join(checker.format_error_annotation(issue) for issue in issues)
+ )
+ print(" Every touched file passes the XSS sink checker in full.")
+ print("Rendering safety test passed!")
+ return True
+
+
+def _run_node_test(path):
+ """Execute one of the Node runtime tests, reporting its result."""
+ if not path.exists():
+ raise AssertionError(f"The runtime test is missing: {path}")
+
+ if not (NODE_MODULES / "zustand").exists():
+ print(" application/v2_ui/node_modules is not installed; skipping the runtime test.")
+ print(" Install it with: npm ci --prefix application/v2_ui")
+ return True
+
+ node = shutil.which("node")
+ if not node:
+ print(" Node is not installed; skipping the runtime test.")
+ print(f" Run it with: node {path.relative_to(REPO_ROOT)}")
+ return True
+
+ completed = subprocess.run(
+ [node, str(path)],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ cwd=str(REPO_ROOT),
+ )
+ output = (completed.stdout or "") + (completed.stderr or "")
+
+ # Node below 22.6 cannot import TypeScript directly. That is a limitation of the
+ # environment, not a defect in the code under test.
+ if completed.returncode != 0 and "Unknown file extension" in output:
+ print(" This Node cannot import TypeScript directly (needs 22.6 or newer); skipping.")
+ return True
+
+ if completed.returncode != 0:
+ print(output)
+ raise AssertionError(f"{path.name} failed; see the output above.")
+
+ # A file that registered no tests exits cleanly too, so the count has to be positive.
+ passed = re.search(r"(?m)^.{0,3}pass (\d+)\s*$", output)
+ failed = re.search(r"(?m)^.{0,3}fail (\d+)\s*$", output)
+ if passed is None or failed is None:
+ raise AssertionError(f"{path.name} printed no test summary:\n{output}")
+ if int(passed.group(1)) < 1 or int(failed.group(1)) != 0:
+ print(output)
+ raise AssertionError(
+ f"{path.name} reported {passed.group(1)} passing and {failed.group(1)} failing tests."
+ )
+
+ print(f" {path.name}: {passed.group(1)} tests passed.")
+ return True
+
+
+def test_pure_rules_behave():
+ """Run the runtime checks for placement, staleness, countdowns, links and notices.
+
+ The assertions above prove the pieces are wired together. They cannot prove that an older
+ snapshot is ignored or that an unsafe link is refused, so the companion Node test executes
+ the real modules.
+ """
+ print("Testing the pure rules...")
+ return _run_node_test(LOGIC_TEST)
+
+
+def test_store_behaves():
+ """Run the runtime checks for the store: what Send, Send now and Cancel do to a card.
+
+ Sending the wrong version of an action, or sending twice, cannot be undone: the email is
+ delivered. Neither is visible from the source.
+ """
+ print("Testing the store...")
+ return _run_node_test(STORE_TEST)
+
+
+def test_the_live_paths_behave():
+ """Run the runtime checks for the stream, a real send, shared events and notice hand-off."""
+ print("Testing the live paths...")
+ return _run_node_test(STREAM_TEST)
+
+
+def test_version_was_incremented():
+ """The application version records when this shipped."""
+ print("Testing version...")
+ version = assert_app_version_at_least(
+ IMPLEMENTED_IN,
+ reason="V2 chat renders Microsoft 365 pending-action cards.",
+ )
+ print(f" config.py VERSION is {version}.")
+ print("Version test passed!")
+ return True
+
+
+if __name__ == "__main__":
+ tests = [
+ test_new_modules_and_tests_exist,
+ test_one_card_serves_the_approvals_page_and_the_chat,
+ test_the_chat_thread_places_cards_like_classic,
+ test_a_failed_reply_still_shows_the_action_it_saved,
+ test_a_reply_that_ends_reads_the_saved_actions_again,
+ test_a_detached_reply_is_no_longer_a_live_turn,
+ test_notices_open_the_v2_chat_with_the_card_named,
+ test_the_server_writes_the_link_parameter_the_client_reads,
+ test_the_conversation_can_be_listed_without_a_backend_change,
+ test_no_html_sink_or_remote_asset_was_introduced,
+ test_pure_rules_behave,
+ test_store_behaves,
+ test_the_live_paths_behave,
+ test_version_was_incremented,
+ ]
+
+ results = []
+ for test in tests:
+ print(f"\nRunning {test.__name__}...")
+ try:
+ results.append(test())
+ except Exception as error: # noqa: BLE001
+ print(f"FAILED: {error}")
+ import traceback
+
+ traceback.print_exc()
+ results.append(False)
+
+ print(f"\nResults: {sum(1 for r in results if r)}/{len(results)} tests passed")
+ sys.exit(0 if all(results) else 1)
diff --git a/functional_tests/test_v2_m365_pending_actions_logic.mjs b/functional_tests/test_v2_m365_pending_actions_logic.mjs
new file mode 100644
index 000000000..074a2c90f
--- /dev/null
+++ b/functional_tests/test_v2_m365_pending_actions_logic.mjs
@@ -0,0 +1,558 @@
+// test_v2_m365_pending_actions_logic.mjs
+// Version: 0.261.307
+// Implemented in: 0.261.307
+// Executes the pure rules behind the Microsoft 365 outgoing-action cards in V2 chat: which
+// snapshot of an action to trust, where a card is drawn in the thread, how a card is recognised
+// in a message or a stream frame, which links a card may carry, and how a notification about a
+// saved action finds its way to the V2 chat page. Nothing here touches the network or React.
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import './test_support/tsResolve.mjs';
+
+function refuseNetwork() {
+ throw new Error('The pure pending-action rules never reach the network.');
+}
+
+globalThis.fetch = refuseNetwork;
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const helpers = await import('../application/v2_ui/src/lib/m365PendingActions.ts');
+const urls = await import('../application/v2_ui/src/lib/conversationUrl.ts');
+const { normalizeNotification } = await import('../application/v2_ui/src/lib/notifications.ts');
+const { resolveNotificationLink } = await import('../application/v2_ui/src/lib/notificationLinks.ts');
+
+const {
+ EMPTY_PENDING_ACTION_REFERENCE,
+ ORPHAN_ANCHOR,
+ PENDING_ACTIONS_UNAVAILABLE_MESSAGE,
+ STREAMING_ANCHOR,
+} = helpers;
+
+const ORIGIN = 'https://simplechat.test';
+const T1 = '2026-05-01T10:00:00Z';
+const T2 = '2026-05-01T10:05:00Z';
+const T3 = '2026-05-01T10:10:00Z';
+
+/** A saved outgoing action as the server serializes it. */
+function action(id = 'act-1', overrides = {}) {
+ return {
+ type: 'msgraph_pending_action',
+ id,
+ version: 'v1',
+ status: 'pending',
+ operation: 'send_mail',
+ graph_resource_type: 'mail',
+ subject: 'Quarterly numbers',
+ summary: { subject: 'Quarterly numbers', body_preview: 'Hello', body_preview_truncated: false },
+ can_cancel: true,
+ can_send_now: true,
+ viewer_is_owner: true,
+ review_details_required: false,
+ updated_at: T1,
+ ...overrides,
+ };
+}
+
+function reference(overrides = {}) {
+ return { ...EMPTY_PENDING_ACTION_REFERENCE, ...overrides };
+}
+
+function anchorContext({ visible = [], byRequest = [], streaming = '' } = {}) {
+ return {
+ visibleMessageIds: new Set(visible),
+ lastMessageByRequestId: new Map(byRequest),
+ streamingUserMessageId: streaming,
+ };
+}
+
+/* Links -------------------------------------------------------------------------------- */
+
+test('a card only links to https addresses that carry no credentials', () => {
+ assert.equal(helpers.safeWebLinkUrl('https://outlook.office.com/mail/id/1'), 'https://outlook.office.com/mail/id/1');
+ for (const unsafe of [
+ 'http://outlook.office.com/mail',
+ 'javascript:alert(1)',
+ 'data:text/html,hi',
+ 'https://user:secret@outlook.office.com/',
+ 'https://user@outlook.office.com/',
+ '//outlook.office.com/mail',
+ 'not a link',
+ '',
+ 42,
+ null,
+ undefined,
+ ]) {
+ assert.equal(helpers.safeWebLinkUrl(unsafe), '', `${String(unsafe)} must not become a link`);
+ }
+});
+
+test('a sharing decision links to its Approvals page, or to the list when it names none', () => {
+ assert.equal(helpers.safeApprovalDecisionHref('req-1'), '/approvals/m365/req-1');
+ assert.equal(helpers.safeApprovalDecisionHref('a b/c'), '/approvals/m365/a%20b%2Fc');
+ for (const none of ['', undefined, null, 5]) {
+ assert.equal(helpers.safeApprovalDecisionHref(none), '/approvals/m365');
+ }
+});
+
+/* Display ------------------------------------------------------------------------------ */
+
+test('the countdown counts down, and says so when the time is missing or has passed', () => {
+ const now = Date.parse('2026-05-01T10:00:00Z');
+ assert.equal(helpers.countdownText(action('a', { auto_send_at_utc: '2026-05-01T10:02:05Z' }), now), 'Scheduled in 2m 5s.');
+ assert.equal(helpers.countdownText(action('a', { auto_send_at_utc: '2026-05-01T10:00:00.400Z' }), now), 'Scheduled in 0m 1s.');
+ const reached = 'Scheduled time reached. Waiting for the server delivery status.';
+ assert.equal(helpers.countdownText(action('a', { auto_send_at_utc: '2026-05-01T10:00:00Z' }), now), reached);
+ assert.equal(helpers.countdownText(action('a', { auto_send_at_utc: '2026-05-01T09:00:00Z' }), now), reached);
+ const missing = 'The scheduled time is unavailable. Refresh status to check it.';
+ assert.equal(helpers.countdownText(action('a'), now), missing);
+ assert.equal(helpers.countdownText(action('a', { auto_send_at_utc: 'soon' }), now), missing);
+});
+
+test('a card is headed by its subject, with the summary and a placeholder behind it', () => {
+ assert.equal(helpers.actionSubject({ subject: 'Top', summary: { subject: 'Inner' } }), 'Top');
+ assert.equal(helpers.actionSubject({ summary: { subject: 'Inner' } }), 'Inner');
+ assert.equal(helpers.actionSubject({ subject: '', summary: {} }), '(No subject)');
+ assert.equal(helpers.actionSubject({}), '(No subject)');
+});
+
+/* Which snapshot to trust -------------------------------------------------------------- */
+
+test('merging a reference keeps what is known, trims what is new, and returns the same object when nothing changed', () => {
+ const empty = EMPTY_PENDING_ACTION_REFERENCE;
+ assert.equal(helpers.mergeReference(empty, null), empty);
+ assert.equal(helpers.mergeReference(empty, undefined), empty);
+ assert.equal(helpers.mergeReference(empty, {}), empty);
+ assert.equal(helpers.mergeReference(empty, { messageId: ' ' }), empty);
+
+ const known = helpers.mergeReference(empty, { messageId: ' m1 ', requestId: 42 });
+ assert.deepEqual(known, reference({ messageId: 'm1', requestId: '42' }));
+
+ // An empty field never erases what was known; a non-empty one replaces it.
+ assert.equal(helpers.mergeReference(known, { messageId: '', userMessageId: '' }), known);
+ assert.equal(helpers.mergeReference(known, { messageId: 'm1' }), known);
+ assert.deepEqual(
+ helpers.mergeReference(known, { messageId: 'm2', userMessageId: 'u1' }),
+ reference({ messageId: 'm2', userMessageId: 'u1', requestId: '42' }),
+ );
+});
+
+test('only pending, scheduled and review-required actions can still be changed', () => {
+ for (const status of ['pending', 'scheduled', 'review_required']) {
+ assert.equal(helpers.isActionableStatus(status), true, status);
+ }
+ for (const status of ['sending', 'sent', 'cancelled', 'canceled', 'failed', 'recovery_required', '', undefined]) {
+ assert.equal(helpers.isActionableStatus(status), false, String(status));
+ }
+});
+
+test('a stale snapshot is ignored: older, a finished action moved back to sendable, or a version already left behind', () => {
+ const current = action('a', { version: 'v2', updated_at: T2 });
+ const seen = new Set(['v1']);
+
+ assert.equal(helpers.isStaleSnapshot(current, seen, action('a', { version: 'v2', updated_at: T1 }), true), true, 'older');
+ assert.equal(helpers.isStaleSnapshot(current, seen, action('a', { version: 'v2', updated_at: T2 }), true), false, 'same');
+ assert.equal(helpers.isStaleSnapshot(current, seen, action('a', { version: 'v3', updated_at: T3 }), true), false, 'newer');
+ assert.equal(helpers.isStaleSnapshot(current, seen, action('a', { version: 'v1', updated_at: T3 }), true), true, 'revisited');
+ // A version the card holds now is not "revisited" when the same version arrives again.
+ assert.equal(helpers.isStaleSnapshot(current, new Set(['v2']), action('a', { version: 'v2', updated_at: T3 }), true), false);
+ // Without timestamps nothing can be called older.
+ assert.equal(
+ helpers.isStaleSnapshot(
+ action('a', { updated_at: undefined }), seen, action('a', { updated_at: undefined }), true,
+ ),
+ false,
+ );
+
+ const sent = action('a', { status: 'sent', version: 'v2', updated_at: T2 });
+ const pendingAgain = action('a', { status: 'pending', version: 'v2', updated_at: T2 });
+ assert.equal(helpers.isStaleSnapshot(sent, seen, pendingAgain, false), true, 'a frame must not un-send an action');
+ assert.equal(helpers.isStaleSnapshot(sent, seen, pendingAgain, true), false, 'the server is the authority');
+ const stillSent = action('a', { status: 'failed', version: 'v2', updated_at: T2 });
+ assert.equal(helpers.isStaleSnapshot(sent, seen, stillSent, false), false, 'one finished state to another is allowed');
+});
+
+test('sign-in is asked for only until the reviewed version is acknowledged, and never of a non-owner', () => {
+ assert.deepEqual(helpers.hydratedAuth(action('a', { viewer_is_owner: false, auth_required: true }), true, 'v1', { auth_required: true }), {
+ auth: null,
+ acknowledgedVersion: '',
+ });
+
+ const calendar = helpers.hydratedAuth(action('a', { auth_required: true, graph_resource_type: 'calendar' }), false, '', null);
+ assert.deepEqual(calendar.auth, { auth_required: true, sources: ['calendar'], scopes: [] });
+ const mail = helpers.hydratedAuth(action('a', { auth_required: true }), false, '', null);
+ assert.deepEqual(mail.auth.sources, ['email']);
+ const named = helpers.hydratedAuth(action('a', { auth_required: true, sources: ['email', 'calendar'], scopes: ['Mail.Send'] }), false, '', null);
+ assert.deepEqual(named.auth, { auth_required: true, sources: ['email', 'calendar'], scopes: ['Mail.Send'] });
+
+ // Acknowledged for this exact version: reconnecting once is enough.
+ assert.deepEqual(
+ helpers.hydratedAuth(action('a', { auth_required: true, version: 'v1' }), true, 'v1', { auth_required: true }),
+ { auth: null, acknowledgedVersion: 'v1' },
+ );
+ assert.notEqual(helpers.hydratedAuth(action('a', { auth_required: true, version: 'v2' }), true, 'v1', null).auth, null);
+
+ // Only the server can say sign-in is no longer needed.
+ const pending = { auth_required: true, sources: ['email'] };
+ assert.equal(helpers.hydratedAuth(action('a', { auth_required: false }), true, '', pending).auth, null);
+ assert.equal(helpers.hydratedAuth(action('a', { auth_required: false }), false, '', pending).auth, pending);
+ assert.equal(helpers.hydratedAuth(action('a'), false, '', pending).auth, pending);
+});
+
+test('the complete saved content counts as loaded only when the server said so for a full read', () => {
+ assert.equal(helpers.hasLoadedFullDetails(action('a'), true), true);
+ assert.equal(helpers.hasLoadedFullDetails(action('a', { summary: { body_preview: '' } }), true), true, 'an empty body is still a body');
+ assert.equal(helpers.hasLoadedFullDetails(action('a'), false), false, 'a frame is not a full read');
+ assert.equal(helpers.hasLoadedFullDetails(action('a', { review_details_required: undefined }), true), false);
+ assert.equal(helpers.hasLoadedFullDetails(action('a', { review_details_required: true }), true), false);
+ assert.equal(helpers.hasLoadedFullDetails(action('a', { summary: { body_preview: 'x', body_preview_truncated: true } }), true), false);
+ assert.equal(helpers.hasLoadedFullDetails(action('a', { summary: {} }), true), false);
+});
+
+/* Polling ------------------------------------------------------------------------------ */
+
+test('a scheduled action ticks, and is polled only once its time has come or a send is running', () => {
+ const now = Date.parse('2026-05-01T10:00:00Z');
+ const past = { will_auto_send: true, status: 'scheduled', auto_send_at_utc: '2026-05-01T09:59:00Z' };
+ const future = { will_auto_send: true, status: 'scheduled', auto_send_at_utc: '2026-05-01T10:30:00Z' };
+
+ assert.equal(helpers.isScheduled(action('a', past)), true);
+ assert.equal(helpers.isScheduled(action('a', { ...past, auto_send_at_utc: 'soon' })), false);
+ assert.equal(helpers.isScheduled(action('a', { ...past, status: 'sent' })), false);
+ assert.equal(helpers.isScheduled(action('a', { ...past, will_auto_send: false })), false);
+
+ assert.equal(helpers.needsTicking(action('a', future)), true);
+ assert.equal(helpers.needsTicking(action('a', { status: 'sending' })), true);
+ assert.equal(helpers.needsTicking(action('a', { ...future, status: 'sent' })), false);
+ assert.equal(helpers.needsTicking(action('a', { status: 'pending' })), false);
+
+ assert.equal(helpers.isPollDue(action('a', past), false, 0, now), true);
+ assert.equal(helpers.isPollDue(action('a', past), true, 0, now), false, 'never while a request is running');
+ assert.equal(helpers.isPollDue(action('a', past), false, now + 1, now), false, 'not before the next allowed time');
+ assert.equal(helpers.isPollDue(action('a', future), false, 0, now), false, 'not before it is due');
+ assert.equal(helpers.isPollDue(action('a', { status: 'sending' }), false, 0, now), true);
+ assert.equal(helpers.isPollDue(action('a', { status: 'sending' }), true, 0, now), false);
+ assert.equal(helpers.isPollDue(action('a', { ...past, auto_send_at_utc: 'soon' }), false, 0, now), false);
+});
+
+/* Reading what the server sent --------------------------------------------------------- */
+
+test('actions are collected from a payload, and anything that is not a saved action is ignored', () => {
+ assert.deepEqual(helpers.pendingActionsFromPayload(null), []);
+ assert.deepEqual(helpers.pendingActionsFromPayload('text'), []);
+ assert.deepEqual(helpers.pendingActionsFromPayload({}), []);
+
+ const one = action('one');
+ const two = action('two');
+ const found = helpers.pendingActionsFromPayload({
+ pending_action: one,
+ m365_pending_actions: [two, { type: 'other', id: 'x' }, { type: 'msgraph_pending_action', id: '..' }, { type: 'msgraph_pending_action', id: ' ' }, null],
+ });
+ assert.deepEqual(found.map((item) => item.id), ['one', 'two']);
+ assert.deepEqual(helpers.pendingActionsFromPayload({ pending_action: { id: 'no-type' } }), []);
+ assert.deepEqual(helpers.pendingActionsFromPayload({ m365_pending_actions: 'nope' }), []);
+});
+
+test('a failure to build the cards is reported in the server wording, or with a fixed fallback', () => {
+ assert.equal(helpers.pendingActionsErrorFromPayload(null), '');
+ assert.equal(helpers.pendingActionsErrorFromPayload({}), '');
+ assert.equal(helpers.pendingActionsErrorFromPayload({ m365_pending_actions_error: null }), '');
+ assert.equal(helpers.pendingActionsErrorFromPayload({ m365_pending_actions_error: { message: ' Cards failed. ' } }), 'Cards failed.');
+ assert.equal(helpers.pendingActionsErrorFromPayload({ m365_pending_actions_error: 'Plain text' }), 'Plain text');
+ for (const bare of [{ error: 'm365_pending_actions_unavailable' }, {}, true]) {
+ assert.equal(
+ helpers.pendingActionsErrorFromPayload({ m365_pending_actions_error: bare }),
+ PENDING_ACTIONS_UNAVAILABLE_MESSAGE,
+ );
+ }
+ assert.match(PENDING_ACTIONS_UNAVAILABLE_MESSAGE, /Do not repeat the request/);
+});
+
+test('the ids a message says belong to it are deduplicated and must be safe to carry', () => {
+ const ids = (values) => helpers.referencedActionIds({ metadata: { m365_pending_action_ids: values } });
+ assert.deepEqual(ids(['a', ' a ', 'b', 'a']), ['a', 'b']);
+ assert.deepEqual(ids(['a/b', 'a\\b', 'a?b', 'a#b', '.', '..', '', ' ', 5, null, {}, 'ok']), ['ok']);
+ assert.deepEqual(ids('a'), []);
+ assert.deepEqual(helpers.referencedActionIds({}), []);
+ assert.deepEqual(helpers.referencedActionIds(null), []);
+ assert.deepEqual(helpers.referencedActionIds({ metadata: {} }), []);
+});
+
+test('a message gives its cards its own id and the request that created them', () => {
+ assert.deepEqual(
+ helpers.referenceForMessage({ id: ' m1 ', metadata: { m365_request_id: 'r1' } }),
+ reference({ messageId: 'm1', requestId: 'r1' }),
+ );
+ assert.deepEqual(
+ helpers.referenceForMessage({ message_id: 'm2', request_id: 'r2' }),
+ reference({ messageId: 'm2', requestId: 'r2' }),
+ );
+ assert.deepEqual(helpers.referenceForMessage({ id: 'm3', message_id: 'other', request_id: 'r3', metadata: { m365_request_id: 'r4' } }),
+ reference({ messageId: 'm3', requestId: 'r4' }));
+ assert.deepEqual(helpers.referenceForMessage(null), reference());
+ assert.deepEqual(helpers.referenceForMessage('text'), reference());
+});
+
+test('a stream frame places a card under the user turn until the reply exists', () => {
+ // A creation frame has no reply yet, so whatever message id it carries is not the card's home.
+ assert.deepEqual(
+ helpers.referenceForStreamFrame(
+ { type: 'm365_pending_action', message_id: 'not-a-reply', user_message_id: 'u1', request_id: 'r1' },
+ { userMessageId: 'pending-1' },
+ ),
+ reference({ userMessageId: 'u1', fallbackMessageId: 'pending-1', requestId: 'r1' }),
+ );
+
+ // A terminal frame names the saved reply, and the user turn comes from the stream.
+ assert.deepEqual(
+ helpers.referenceForStreamFrame(
+ { done: true, message_id: 'reply-1' },
+ { userMessageId: 'u2', requestId: 'r9' },
+ ),
+ reference({ messageId: 'reply-1', userMessageId: 'u2', fallbackMessageId: 'u2', requestId: 'r9' }),
+ );
+
+ // What the caller knows about the viewed conversation outranks the frame's own message id.
+ assert.equal(helpers.referenceForStreamFrame({ done: true, message_id: 'reply-1' }, { messageId: 'mine' }).messageId, 'mine');
+ // The request id is the frame's, then its metadata's, then the caller's.
+ assert.equal(
+ helpers.referenceForStreamFrame({ request_id: 'a', metadata: { m365_request_id: 'b' } }, { requestId: 'c' }).requestId,
+ 'a',
+ );
+ assert.equal(helpers.referenceForStreamFrame({ metadata: { m365_request_id: 'b' } }, { requestId: 'c' }).requestId, 'b');
+ assert.equal(helpers.referenceForStreamFrame({}, { requestId: 'c' }).requestId, 'c');
+ assert.deepEqual(helpers.referenceForStreamFrame(null), reference());
+});
+
+test('a message is re-read only when it brings new or different cards', () => {
+ const card = action('a1', { version: 'v1', updated_at: T1, status: 'pending' });
+ const message = { id: 'm1', metadata: { m365_request_id: 'r1', m365_pending_action_ids: ['b'] }, m365_pending_actions: [card] };
+
+ assert.equal(helpers.messageTrackKey(message), `m1|r1|a1@v1@${T1}@pending|b`);
+ assert.equal(helpers.messageTrackKey({ ...message }), helpers.messageTrackKey(message), 'stable for the same content');
+
+ assert.equal(helpers.messageTrackKey({ id: 'm2' }), '', 'nothing to track');
+ assert.equal(helpers.messageTrackKey({ role: 'assistant', m365_pending_actions: [card] }), '', 'no id to attribute it to');
+ assert.equal(helpers.messageTrackKey({ id: 'm3', m365_pending_actions: [{ id: 'x' }], metadata: {} }), '', 'not a saved action');
+ assert.equal(helpers.messageTrackKey(null), '');
+ assert.equal(helpers.messageTrackKey({ message_id: 'm4', metadata: { m365_pending_action_ids: ['z'] } }), 'm4|||z');
+
+ for (const changed of [
+ { version: 'v2' },
+ { updated_at: T2 },
+ { status: 'sent' },
+ ]) {
+ assert.notEqual(
+ helpers.messageTrackKey({ ...message, m365_pending_actions: [{ ...card, ...changed }] }),
+ helpers.messageTrackKey(message),
+ JSON.stringify(changed),
+ );
+ }
+});
+
+/* Placement ---------------------------------------------------------------------------- */
+
+test('a card is drawn under the reply that saved it when that reply is on screen', () => {
+ const context = anchorContext({ visible: ['reply', 'u1'], streaming: 'u1' });
+ assert.equal(
+ helpers.resolveAnchor(reference({ messageId: 'reply', userMessageId: 'u1' }), {}, context),
+ 'reply',
+ 'a visible reply wins even while a later turn streams',
+ );
+});
+
+test('a card saved by the reply that is still streaming goes to the streaming slot', () => {
+ const context = anchorContext({ visible: ['u1'], streaming: 'u1' });
+ assert.equal(helpers.resolveAnchor(reference({ userMessageId: 'u1' }), {}, context), STREAMING_ANCHOR);
+ assert.equal(helpers.resolveAnchor(reference({ fallbackMessageId: 'u1' }), {}, context), STREAMING_ANCHOR);
+ assert.equal(
+ helpers.resolveAnchor(reference({ messageId: 'gone', userMessageId: 'u1' }), {}, context),
+ STREAMING_ANCHOR,
+ 'a reply that is not on screen is not an anchor',
+ );
+ assert.equal(
+ helpers.resolveAnchor(reference({ userMessageId: 'u0' }), {}, context),
+ ORPHAN_ANCHOR,
+ 'a card for another turn is not drawn under the one that streams',
+ );
+});
+
+test('without a visible reply the card falls back to its user turn, then its request, then the conversation section', () => {
+ assert.equal(helpers.resolveAnchor(reference({ userMessageId: 'u1' }), {}, anchorContext({ visible: ['u1'] })), 'u1');
+ assert.equal(helpers.resolveAnchor(reference({ userMessageId: 'x', fallbackMessageId: 'u2' }), {}, anchorContext({ visible: ['u2'] })), 'u2');
+
+ const byRequest = anchorContext({ visible: ['m9'], byRequest: [['r1', 'm9'], ['r2', 'hidden']] });
+ assert.equal(helpers.resolveAnchor(reference({ requestId: 'r1' }), {}, byRequest), 'm9');
+ assert.equal(helpers.resolveAnchor(reference(), { request_id: 'r1' }, byRequest), 'm9', 'the action itself can name the request');
+ assert.equal(helpers.resolveAnchor(reference({ requestId: 'r2' }), {}, byRequest), ORPHAN_ANCHOR, 'a hidden message is no anchor');
+ assert.equal(helpers.resolveAnchor(reference({ requestId: 'unknown' }), {}, byRequest), ORPHAN_ANCHOR);
+
+ assert.equal(helpers.resolveAnchor(reference(), {}, anchorContext({ visible: ['m1'] })), ORPHAN_ANCHOR);
+ // An empty id must never match an empty member of the visible set.
+ assert.equal(helpers.resolveAnchor(reference(), {}, anchorContext({ visible: [''] })), ORPHAN_ANCHOR);
+ assert.equal(ORPHAN_ANCHOR, '');
+ assert.notEqual(STREAMING_ANCHOR, ORPHAN_ANCHOR);
+});
+
+test('messages are indexed by request, and the newest user turn is the one that streams', () => {
+ const index = helpers.lastMessageByRequestId([
+ { id: 'a', metadata: { m365_request_id: 'r1' } },
+ { id: 'b', metadata: { m365_request_id: 'r1' } },
+ { id: 'c' },
+ { id: '', metadata: { m365_request_id: 'r2' } },
+ { id: 'd', metadata: { m365_request_id: ' ' } },
+ ]);
+ assert.deepEqual([...index], [['r1', 'b']]);
+
+ assert.equal(helpers.latestUserMessageId([
+ { id: 'u1', role: 'user' }, { id: 'a1', role: 'assistant' }, { id: 'u2', role: 'user' }, { id: 'a2', role: 'assistant' },
+ ]), 'u2');
+ assert.equal(helpers.latestUserMessageId([{ id: 'a1', role: 'assistant' }]), '');
+ assert.equal(helpers.latestUserMessageId([{ id: ' ', role: 'user' }, { id: 'u1', role: 'user' }, { role: 'user' }]), 'u1');
+ assert.equal(helpers.latestUserMessageId([]), '');
+});
+
+/* The URL vocabulary -------------------------------------------------------------------- */
+
+test('an action id is carried only when it is safe in a path, a query and a selector', () => {
+ assert.equal(urls.normalizePendingActionId('act-1'), 'act-1');
+ assert.equal(urls.normalizePendingActionId(' act-1 '), 'act-1');
+ assert.equal(urls.normalizePendingActionId('x'.repeat(200)), 'x'.repeat(200));
+ for (const unsafe of [
+ 'a/b', 'a\\b', 'a?b', 'a#b', '.', '..', '', ' ', 'x'.repeat(201), 'a\nb', 'a\u0000b', 'a\u007fb',
+ 5, null, undefined, {}, [],
+ ]) {
+ assert.equal(urls.normalizePendingActionId(unsafe), '', `${JSON.stringify(unsafe)} must be refused`);
+ }
+ assert.equal(helpers.normalizePendingActionId, urls.normalizePendingActionId, 'one rule for links and cards');
+});
+
+test('the one-shot focus parameter is read only when it names a safe id', () => {
+ assert.equal(urls.M365_PENDING_ACTION_PARAM, 'm365_pending_action');
+ assert.equal(urls.readPendingActionFocus(new URLSearchParams('m365_pending_action=act-1')), 'act-1');
+ assert.equal(urls.readPendingActionFocus(new URLSearchParams('m365_pending_action=%20act-1%20')), 'act-1');
+ for (const query of ['', 'conversationId=c', 'm365_pending_action=', 'm365_pending_action=a%2Fb', 'm365_pending_action=..']) {
+ assert.equal(urls.readPendingActionFocus(new URLSearchParams(query)), null, query);
+ }
+});
+
+test('a link to a saved action opens its conversation, and still opens it when the id cannot be carried', () => {
+ assert.equal(urls.chatHrefForPendingAction('conv-1', 'act-1'), '/chat?conversationId=conv-1&m365_pending_action=act-1');
+ assert.equal(
+ urls.chatHrefForPendingAction('conv 1&x', 'act 1'),
+ '/chat?conversationId=conv%201%26x&m365_pending_action=act%201',
+ 'both ids are encoded',
+ );
+ assert.equal(urls.chatHrefForPendingAction('conv-1', 'a/b'), urls.chatHrefForConversation('conv-1'));
+ assert.equal(urls.chatHrefForPendingAction('conv-1', ''), '/chat?conversationId=conv-1');
+});
+
+test('the address sync strips the one-shot focus parameter and keeps the conversation', () => {
+ const arrived = new URLSearchParams('conversationId=conv-1&m365_pending_action=act-1');
+ const synced = urls.syncedConversationParams(arrived, 'conv-1');
+ assert.ok(synced, 'the parameter is a difference that must be written back');
+ assert.equal(synced.has('m365_pending_action'), false);
+ assert.equal(synced.get('conversationId'), 'conv-1');
+
+ // Other parameters survive, and the same address does not rewrite itself again.
+ const other = urls.syncedConversationParams(new URLSearchParams('conversationId=conv-1&m365_pending_action=act-1&keep=1'), 'conv-1');
+ assert.equal(other.get('keep'), '1');
+ assert.equal(urls.syncedConversationParams(synced, 'conv-1'), null);
+ assert.equal(urls.syncedConversationParams(new URLSearchParams('conversationId=conv-1'), 'conv-1'), null);
+
+ // Even a parameter that was refused as unsafe is removed rather than left to linger.
+ const refused = urls.syncedConversationParams(new URLSearchParams('conversationId=conv-1&m365_pending_action=a%2Fb'), 'conv-1');
+ assert.equal(refused.has('m365_pending_action'), false);
+ // A conversation change still rewrites the parameter it owns.
+ assert.equal(urls.syncedConversationParams(new URLSearchParams('conversationId=conv-1'), 'conv-2').get('conversationId'), 'conv-2');
+});
+
+/* Where a notification about a saved action goes ---------------------------------------- */
+
+function notice(overrides = {}) {
+ return normalizeNotification({
+ id: 'n-1',
+ notification_type: 'system_announcement',
+ title: 'A Microsoft 365 action needs your attention',
+ message: 'An email is waiting to be sent.',
+ created_at: '2026-05-01T09:05:00Z',
+ is_read: false,
+ link_url: '',
+ link_context: {},
+ metadata: {},
+ type_config: { icon: 'bi-envelope', color: 'info' },
+ ...overrides,
+ });
+}
+
+const resolve = (overrides) => resolveNotificationLink(notice(overrides), ORIGIN);
+const conversation = (conversationId, pendingActionId) => ({
+ target: pendingActionId
+ ? { kind: 'conversation', conversationId, pendingActionId }
+ : { kind: 'conversation', conversationId },
+ error: null,
+});
+
+test('a notice about a saved action opens the V2 conversation and names the action, whichever chat address it carries', () => {
+ const metadata = { m365_pending_action_id: 'act-1', conversation_id: 'conv-1' };
+ for (const link of [
+ '/chats?conversationId=conv-1&m365_pending_action=act-1',
+ '/chats?conversation_id=conv-1&m365_pending_action=act-1',
+ '/chat?conversationId=conv-1&m365_pending_action=act-1',
+ '/v2/chat?conversationId=conv-1&m365_pending_action=act-1',
+ '/chats/?conversationId=conv-1&m365_pending_action=act-1',
+ `${ORIGIN}/chats?conversationId=conv-1&m365_pending_action=act-1`,
+ ]) {
+ assert.deepEqual(resolve({ link_url: link, metadata }), conversation('conv-1', 'act-1'), link);
+ }
+});
+
+test('a notice about a saved action never becomes a classic page', () => {
+ const resolved = resolve({
+ link_url: '/chats?conversationId=conv-1&m365_pending_action=act-1',
+ metadata: { m365_pending_action_id: 'act-1' },
+ });
+ assert.equal(resolved.error, null);
+ assert.equal(resolved.target.kind, 'conversation');
+ assert.equal('href' in resolved.target, false, 'no classic address is built for it');
+});
+
+test('an action id a link must not carry opens the conversation without a focus request', () => {
+ for (const bad of ['act%2F1', 'act%5C1', 'act%3F1', 'act%231', '..', '.', '', '%00', 'x'.repeat(201)]) {
+ assert.deepEqual(
+ resolve({ link_url: `/chats?conversationId=conv-1&m365_pending_action=${bad}`, metadata: { m365_pending_action_id: 'act-1' } }),
+ conversation('conv-1'),
+ bad,
+ );
+ }
+});
+
+test('the conversation id still has to be one a path can carry', () => {
+ const invalid = 'This notification has an invalid link. Open the destination directly.';
+ for (const bad of ['conv%2F1', 'conv%5C1', '..']) {
+ assert.deepEqual(
+ resolve({ link_url: `/chats?conversationId=${bad}&m365_pending_action=act-1`, metadata: { m365_pending_action_id: 'act-1' } }),
+ { target: null, error: invalid },
+ bad,
+ );
+ }
+ // A chat link with no conversation opens the chat page itself, never an action on its own.
+ assert.deepEqual(resolve({ link_url: '/chats?m365_pending_action=act-1' }), { target: { kind: 'route', path: '/chat' }, error: null });
+});
+
+test('a notice about a saved action without a conversation opens the V2 Approvals page', () => {
+ assert.deepEqual(resolve({ link_url: '/approvals' }), { target: { kind: 'route', path: '/approvals' }, error: null });
+ assert.deepEqual(
+ resolve({ link_url: '/approvals?status=pending#m365' }),
+ { target: { kind: 'route', path: '/approvals?status=pending#m365' }, error: null },
+ );
+});
+
+test('a link to another site is never followed, whatever it says about an action', () => {
+ const resolved = resolve({
+ link_url: 'https://elsewhere.test/chats?conversationId=conv-1&m365_pending_action=act-1',
+ metadata: { m365_pending_action_id: 'act-1' },
+ });
+ assert.equal(resolved.target, null);
+ assert.match(resolved.error, /another site/);
+});
diff --git a/functional_tests/test_v2_m365_pending_actions_store.mjs b/functional_tests/test_v2_m365_pending_actions_store.mjs
new file mode 100644
index 000000000..6511425cd
--- /dev/null
+++ b/functional_tests/test_v2_m365_pending_actions_store.mjs
@@ -0,0 +1,1170 @@
+// test_v2_m365_pending_actions_store.mjs
+// Version: 0.261.307
+// Implemented in: 0.261.307
+// Executes the state machine behind the Microsoft 365 outgoing-action cards against a fake server:
+// how a card is found, when it may be sent or cancelled, what happens when the server disagrees
+// with what the page shows, and how a late answer for a conversation the person has left is
+// dropped. The store is the same one the chat view and the Approvals detail page draw from, so
+// these tests cover both places a card appears.
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import './test_support/tsResolve.mjs';
+
+globalThis.fetch = () => {
+ throw new Error('A test reached the network without installing its fake server first.');
+};
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const storeModule = await import('../application/v2_ui/src/stores/m365PendingActionsStore.ts');
+
+const { LIST_FAILED_MESSAGE, LIST_PERMISSION_MESSAGE, REFERENCE_FAILED_MESSAGE, canChangeEntry, createPendingActionsStore } =
+ storeModule;
+
+const NOT_LOADED_MESSAGE = 'The outgoing action could not be loaded.';
+const PERMISSION_MESSAGE = 'You do not have permission to access this action. No action was sent.';
+
+const T1 = '2026-05-01T10:00:00Z';
+const T2 = '2026-05-01T10:05:00Z';
+const T3 = '2026-05-01T10:10:00Z';
+
+const LIST_PATH = '/api/msgraph/pending-actions';
+const ITEM_PATH = /^\/api\/msgraph\/pending-actions\/([^/]+)(?:\/(send-now|approve|cancel))?$/;
+
+/** A saved outgoing action as the server serializes it. */
+function action(id = 'act-1', overrides = {}) {
+ return {
+ type: 'msgraph_pending_action',
+ id,
+ version: 'v1',
+ status: 'pending',
+ operation: 'send_mail',
+ graph_resource_type: 'mail',
+ subject: 'Quarterly numbers',
+ summary: { subject: 'Quarterly numbers', body_preview: 'Hello', body_preview_truncated: false },
+ can_cancel: true,
+ can_send_now: true,
+ viewer_is_owner: true,
+ review_details_required: false,
+ updated_at: T1,
+ ...overrides,
+ };
+}
+
+/** A chat message that carries, or points at, saved actions. */
+function message(id, extra = {}) {
+ return { id, role: 'assistant', content: 'I saved the email for you to review.', ...extra };
+}
+
+function json(body, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+function deferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+/** Wait, turn by turn, for work the store started without handing back a promise. */
+async function settled(condition, description = 'the condition') {
+ for (let turn = 0; turn < 500; turn += 1) {
+ if (condition()) return;
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ throw new Error(`${description} was never met`);
+}
+
+/**
+ * Stand in for the server. `handler` receives each request after the Microsoft 365 CSRF token
+ * request (which is always answered and kept in `all` only) and returns a Response; returning
+ * nothing marks the request as one the test did not expect.
+ */
+function serve(handler, { honorAbort = true } = {}) {
+ const previous = globalThis.fetch;
+ const calls = [];
+ const all = [];
+ const unexpected = [];
+ globalThis.fetch = (input, init = {}) => {
+ const url = new URL(typeof input === 'string' ? input : String(input?.url ?? input), 'http://localhost');
+ const method = String(init.method ?? 'GET').toUpperCase();
+ const matched = ITEM_PATH.exec(url.pathname);
+ let kind = 'other';
+ if (method === 'GET' && url.pathname === LIST_PATH) kind = 'list';
+ else if (method === 'GET' && matched && !matched[2]) kind = 'get';
+ else if (method === 'POST' && matched && matched[2]) kind = 'post';
+ const call = {
+ method,
+ path: url.pathname,
+ query: Object.fromEntries(url.searchParams),
+ headers: new Headers(init.headers ?? {}),
+ body: typeof init.body === 'string' && init.body ? JSON.parse(init.body) : null,
+ kind,
+ id: matched ? decodeURIComponent(matched[1]) : '',
+ operation: matched?.[2] ?? '',
+ };
+ all.push(call);
+ if (method === 'GET' && url.pathname === '/api/m365/preferences') {
+ return Promise.resolve(json({ csrf_token: 'c'.repeat(40) }));
+ }
+ calls.push(call);
+ const work = (async () => {
+ const response = await handler(call);
+ if (!response) {
+ unexpected.push(`${method} ${url.pathname}${url.search}`);
+ throw new Error(`The test did not expect ${method} ${url.pathname}${url.search}`);
+ }
+ return response;
+ })();
+ const signal = init.signal;
+ if (!honorAbort || !signal) return work;
+ return new Promise((resolve, reject) => {
+ const abort = () => reject(new DOMException('The operation was aborted.', 'AbortError'));
+ if (signal.aborted) {
+ abort();
+ return;
+ }
+ signal.addEventListener('abort', abort, { once: true });
+ work.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
+ });
+ };
+ return {
+ calls,
+ all,
+ unexpected,
+ restore() {
+ globalThis.fetch = previous;
+ },
+ };
+}
+
+async function withServer(handler, body, options) {
+ const server = serve(handler, options);
+ try {
+ await body(server);
+ } finally {
+ server.restore();
+ }
+ assert.deepEqual(server.unexpected, [], 'the store asked the server for something the test did not expect');
+}
+
+/** A chat-scope store for one conversation, the way the chat page uses it. */
+function newStore({ conversationId = 'conv-1', onUpdated } = {}) {
+ const store = createPendingActionsStore({ scope: 'chat', onUpdated });
+ if (conversationId) store.getState().setConversation(conversationId);
+ return store;
+}
+
+const stateOf = (store) => store.getState();
+const cardOf = (store, id) => store.getState().entries[id];
+
+/** The server holds one copy of each action and answers reads from it. */
+function readsFrom(held) {
+ return (call) => {
+ if (call.kind !== 'get') return undefined;
+ const found = held.get(call.id);
+ return found ? json({ pending_action: found }) : json({ error: 'not_found', message: 'No such action.' }, 404);
+ };
+}
+
+/* Remembering a card ------------------------------------------------------------------- */
+
+test('a card is remembered once, and anything that is not a saved action is refused', () => {
+ const updates = [];
+ const store = newStore({ onUpdated: (saved) => updates.push(saved.id) });
+ const made = stateOf(store).remember(action('act-1'), { authoritative: true, reference: { messageId: 'm-1' } });
+ assert.equal(made.id, 'act-1');
+ assert.equal(made.reference.messageId, 'm-1');
+ assert.equal(made.fullDetailsVersion, 'v1', 'a full copy from the server counts as reviewed');
+ assert.equal(made.needsRefresh, false);
+ assert.equal(canChangeEntry(made), true);
+ assert.deepEqual(updates, ['act-1']);
+
+ for (const refused of [null, undefined, {}, 'text', 7, { type: 'other', id: 'x' }, { type: 'msgraph_pending_action', id: '..' }]) {
+ assert.equal(stateOf(store).remember(refused), null, `${JSON.stringify(refused)} is not a saved action`);
+ }
+ assert.deepEqual(Object.keys(stateOf(store).entries), ['act-1']);
+ assert.deepEqual(updates, ['act-1']);
+});
+
+test('a copy that did not come from the server is not treated as reviewed in full', () => {
+ const store = newStore();
+ const made = stateOf(store).remember(action('act-1'));
+ assert.equal(made.fullDetailsVersion, '');
+ const second = stateOf(store).remember(action('act-2', { review_details_required: true }), { authoritative: true });
+ assert.equal(second.fullDetailsVersion, '', 'a server copy that withholds the body still needs a full review');
+});
+
+test('an older snapshot is ignored, though the place it was seen is still remembered', () => {
+ const updates = [];
+ const store = newStore({ onUpdated: (saved) => updates.push(saved.subject) });
+ stateOf(store).remember(action('act-1', { updated_at: T2, subject: 'Newest' }), { authoritative: true });
+ assert.equal(updates.length, 1);
+
+ const before = stateOf(store);
+ const kept = stateOf(store).remember(action('act-1', { updated_at: T1, subject: 'Older' }), { authoritative: true });
+ assert.equal(kept.action.subject, 'Newest');
+ assert.equal(stateOf(store), before, 'nothing changed, so nothing is announced to the page');
+ assert.equal(updates.length, 1);
+
+ const placed = stateOf(store).remember(action('act-1', { updated_at: T1, subject: 'Older' }), {
+ reference: { messageId: 'm-9', requestId: 'req-9' },
+ });
+ assert.equal(placed.action.subject, 'Newest');
+ assert.equal(placed.reference.messageId, 'm-9');
+ assert.equal(placed.reference.requestId, 'req-9');
+ assert.equal(updates.length, 1, 'a placement change alone is not an update to the action');
+});
+
+test('a stream or history copy cannot un-send an action, but the server can say what it likes', () => {
+ const store = newStore();
+ stateOf(store).remember(action('act-1', { status: 'sent', updated_at: T2, can_cancel: false, can_send_now: false }), {
+ authoritative: true,
+ });
+ stateOf(store).remember(action('act-1', { status: 'pending', updated_at: T3 }));
+ assert.equal(cardOf(store, 'act-1').action.status, 'sent', 'a copy of unknown age cannot make a sent action actionable again');
+
+ stateOf(store).remember(action('act-1', { status: 'pending', updated_at: T3 }), { authoritative: true });
+ assert.equal(cardOf(store, 'act-1').action.status, 'pending');
+});
+
+test('a new version of an action drops what was true of the old one', () => {
+ const store = newStore();
+ stateOf(store).remember(action('act-1', { auth_required: true, sources: ['email'], scopes: ['Mail.Send'] }), {
+ authoritative: true,
+ });
+ let card = cardOf(store, 'act-1');
+ assert.equal(card.fullDetailsVersion, 'v1');
+ assert.deepEqual(card.auth, { auth_required: true, sources: ['email'], scopes: ['Mail.Send'] });
+
+ stateOf(store).remember(action('act-1', { version: 'v2', updated_at: T2 }));
+ card = cardOf(store, 'act-1');
+ assert.equal(card.action.version, 'v2');
+ assert.deepEqual(card.seenVersions, ['v1']);
+ assert.equal(card.fullDetailsVersion, '', 'the earlier review was of a different version');
+ assert.equal(card.auth, null);
+ assert.equal(card.authAcknowledgedVersion, '');
+
+ stateOf(store).remember(action('act-1', { version: 'v2', updated_at: T2 }), { authoritative: true });
+ assert.equal(cardOf(store, 'act-1').fullDetailsVersion, 'v2');
+});
+
+test('a version the card already moved past cannot come back, even from the server', () => {
+ const store = newStore();
+ stateOf(store).remember(action('act-1', { version: 'v1' }), { authoritative: true });
+ stateOf(store).remember(action('act-1', { version: 'v2', updated_at: T2 }), { authoritative: true });
+ stateOf(store).remember(action('act-1', { version: 'v1', updated_at: T3, subject: 'Resurrected' }), { authoritative: true });
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.action.version, 'v2');
+ assert.notEqual(card.action.subject, 'Resurrected');
+});
+
+test('a stripped copy of a version reviewed in full is checked against the server again', async () => {
+ await withServer(readsFrom(new Map([['act-1', action('act-1', { updated_at: T2 })]])), async (server) => {
+ const store = newStore();
+ stateOf(store).remember(action('act-1'), { authoritative: true });
+ assert.equal(cardOf(store, 'act-1').fullDetailsVersion, 'v1');
+
+ stateOf(store).remember(
+ action('act-1', {
+ updated_at: T2,
+ review_details_required: true,
+ summary: { subject: 'Quarterly numbers', body_preview_truncated: true },
+ }),
+ );
+ assert.equal(cardOf(store, 'act-1').refreshing, true, 'the full content has to be fetched again');
+ await stateOf(store).refresh('act-1');
+
+ assert.equal(server.calls.length, 1);
+ assert.equal(server.calls[0].query.conversation_id, 'conv-1');
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.fullDetailsVersion, 'v1');
+ assert.equal(card.action.review_details_required, false);
+ });
+});
+
+/* Cards found in messages ------------------------------------------------------------- */
+
+test('a card in a loaded message is not sendable until the server has confirmed it', async () => {
+ const held = new Map([['act-1', action('act-1', { updated_at: T2, subject: 'Server copy' })]]);
+ await withServer(readsFrom(held), async (server) => {
+ const store = newStore();
+ const first = message('m-1', { m365_pending_actions: [action('act-1')], metadata: { m365_request_id: 'req-1' } });
+ stateOf(store).ingestMessages([first]);
+
+ let card = cardOf(store, 'act-1');
+ assert.equal(card.needsRefresh, true);
+ assert.equal(canChangeEntry(card), false);
+
+ await stateOf(store).refresh('act-1');
+ card = cardOf(store, 'act-1');
+ assert.equal(server.calls.length, 1);
+ assert.equal(server.calls[0].path, `${LIST_PATH}/act-1`);
+ assert.equal(server.calls[0].query.conversation_id, 'conv-1');
+ assert.equal(card.action.subject, 'Server copy');
+ assert.equal(card.needsRefresh, false);
+ assert.equal(canChangeEntry(card), true);
+ assert.equal(card.reference.messageId, 'm-1');
+ assert.equal(card.reference.requestId, 'req-1');
+
+ stateOf(store).ingestMessages([first]);
+ assert.equal(server.calls.length, 1, 'the same message again changes nothing');
+
+ held.set('act-1', action('act-1', { status: 'sent', updated_at: T3, can_cancel: false, can_send_now: false }));
+ stateOf(store).ingestMessages([
+ { ...first, m365_pending_actions: [action('act-1', { status: 'sent', updated_at: T3, can_cancel: false, can_send_now: false })] },
+ ]);
+ await stateOf(store).refresh('act-1');
+ assert.equal(server.calls.length, 2, 'a card that changed is checked again');
+ assert.equal(cardOf(store, 'act-1').action.status, 'sent');
+ });
+});
+
+test('a card a message only points at is fetched, and a failed fetch is retried together with the list', async () => {
+ const gate = deferred();
+ let failing = true;
+ await withServer(async (call) => {
+ if (call.kind === 'get' && call.id === 'act-2') {
+ if (failing) {
+ await gate.promise;
+ return json({ error: 'upstream_down', message: 'Down.' }, 500);
+ }
+ return json({ pending_action: action('act-2') });
+ }
+ if (call.kind === 'list') return json({ pending_actions: [action('act-2')] });
+ return undefined;
+ }, async (server) => {
+ const store = newStore();
+ stateOf(store).trackMessage(message('m-2', { metadata: { m365_pending_action_ids: ['act-2'], m365_request_id: 'req-2' } }));
+ assert.equal(stateOf(store).referenceLoading, true, 'the card may yet move, so the page waits for it');
+ gate.resolve();
+ await settled(() => !stateOf(store).referenceLoading, 'the failed fetch');
+
+ assert.equal(stateOf(store).referenceError, REFERENCE_FAILED_MESSAGE);
+ assert.equal(cardOf(store, 'act-2'), undefined, 'nothing is shown for a card that could not be verified');
+ assert.equal(server.calls[0].query.conversation_id, 'conv-1');
+
+ failing = false;
+ await stateOf(store).loadList();
+ assert.deepEqual(
+ server.calls.map((call) => call.kind),
+ ['get', 'get', 'list'],
+ 'the missing card is fetched again before the list is read',
+ );
+ assert.equal(stateOf(store).referenceError, '');
+ const card = cardOf(store, 'act-2');
+ assert.equal(card.reference.messageId, 'm-2', 'the list does not forget which reply the card belongs to');
+ assert.equal(card.reference.requestId, 'req-2');
+ });
+});
+
+test('a message that names a card the page already holds checks that card again', async () => {
+ const held = new Map([['act-1', action('act-1', { updated_at: T2 })]]);
+ await withServer(readsFrom(held), async (server) => {
+ const store = newStore();
+ stateOf(store).remember(action('act-1'), { authoritative: true });
+ stateOf(store).trackMessage(message('m-5', { metadata: { m365_pending_action_ids: ['act-1'], m365_request_id: 'req-5' } }));
+ assert.equal(cardOf(store, 'act-1').needsRefresh, true);
+ assert.equal(cardOf(store, 'act-1').reference.messageId, 'm-5');
+
+ await stateOf(store).refresh('act-1');
+ assert.equal(server.calls.length, 1);
+ assert.equal(cardOf(store, 'act-1').needsRefresh, false);
+ assert.equal(cardOf(store, 'act-1').reference.requestId, 'req-5');
+ });
+});
+
+test('a card that needs its full content reviewed is not re-checked behind the reader', async () => {
+ const full = action('act-3', { updated_at: T2 });
+ await withServer(readsFrom(new Map([['act-3', full]])), async (server) => {
+ const store = newStore();
+ stateOf(store).remember(action('act-3', { review_details_required: true }));
+ stateOf(store).trackMessage(message('m-3', { metadata: { m365_pending_action_ids: ['act-3'] } }));
+ assert.equal(server.calls.length, 0, 'the person asks for the full content; the page does not fetch it unprompted');
+ assert.equal(cardOf(store, 'act-3').needsRefresh, true);
+ assert.equal(canChangeEntry(cardOf(store, 'act-3')), false);
+
+ await stateOf(store).reviewFull('act-3');
+ const card = cardOf(store, 'act-3');
+ assert.equal(server.calls.length, 1);
+ assert.equal(card.needsRefresh, false);
+ assert.equal(card.bodyOpen, true);
+ assert.equal(card.detailVersion, 'v1');
+ assert.equal(card.fullDetailsVersion, 'v1');
+ assert.equal(card.reviewing, false);
+ assert.equal(canChangeEntry(card), true);
+ });
+});
+
+/* Reading the conversation's cards ---------------------------------------------------- */
+
+test('the list is read a page at a time and each page is requested only once', async () => {
+ const pages = {
+ '': { pending_actions: [action('a1'), action('a2')], continuation_token: 'tok-2' },
+ 'tok-2': { pending_actions: [action('a3', { updated_at: T2 })] },
+ };
+ await withServer((call) => (call.kind === 'list' ? json(pages[call.query.continuation_token ?? '']) : undefined), async (server) => {
+ const store = newStore();
+ const loading = stateOf(store).loadList();
+ assert.equal(stateOf(store).listStatus, 'loading');
+ await loading;
+
+ assert.deepEqual(server.calls[0].query, { conversation_id: 'conv-1', limit: '30' });
+ assert.equal(stateOf(store).listStatus, 'loaded');
+ assert.equal(stateOf(store).continuationToken, 'tok-2');
+ assert.deepEqual(Object.keys(stateOf(store).entries), ['a1', 'a2']);
+ assert.equal(canChangeEntry(cardOf(store, 'a1')), true, 'the list is the server, so its cards are ready');
+
+ const more = stateOf(store).loadList({ more: true });
+ assert.equal(stateOf(store).loadingMore, true);
+ await more;
+ assert.equal(server.calls[1].query.continuation_token, 'tok-2');
+ assert.equal(stateOf(store).loadingMore, false);
+ assert.equal(stateOf(store).continuationToken, '');
+ const orders = ['a1', 'a2', 'a3'].map((id) => cardOf(store, id).order);
+ assert.deepEqual(orders, [...orders].sort((left, right) => left - right), 'cards keep the order they were found in');
+
+ await stateOf(store).loadList({ more: true });
+ assert.equal(server.calls.length, 2, 'there is nothing more to ask for');
+ });
+});
+
+test('a list nobody may read says so, and nothing already on the page can be sent', async () => {
+ await withServer(() => json({ error: 'forbidden', message: 'No.' }, 403), async () => {
+ const store = newStore();
+ stateOf(store).remember(action('a1'), { authoritative: true });
+ await stateOf(store).loadList();
+ assert.equal(stateOf(store).listStatus, 'error');
+ assert.equal(stateOf(store).listError, LIST_PERMISSION_MESSAGE);
+ const card = cardOf(store, 'a1');
+ assert.equal(card.denied, true);
+ assert.equal(card.needsRefresh, true);
+ assert.equal(canChangeEntry(card), false);
+ });
+});
+
+test('a list that failed is not mistaken for an empty one', async () => {
+ await withServer(() => json({ error: 'upstream_down', message: 'Down.' }, 500), async () => {
+ const store = newStore();
+ stateOf(store).remember(action('a1'), { authoritative: true });
+ await stateOf(store).loadList();
+ assert.equal(stateOf(store).listStatus, 'error');
+ assert.equal(stateOf(store).listError, LIST_FAILED_MESSAGE);
+ const card = cardOf(store, 'a1');
+ assert.equal(card.denied, false, 'a server error is not a refusal');
+ assert.equal(card.needsRefresh, true, 'but the card can no longer be trusted until it is checked');
+ assert.equal(canChangeEntry(card), false);
+ });
+});
+
+test('a page with anything that is not a saved action is refused whole', async () => {
+ await withServer(() => json({ pending_actions: [action('a1'), { id: 'not-an-action' }] }), async () => {
+ const store = newStore();
+ await stateOf(store).loadList();
+ assert.equal(stateOf(store).listStatus, 'error');
+ assert.equal(stateOf(store).listError, LIST_FAILED_MESSAGE);
+ assert.deepEqual(Object.keys(stateOf(store).entries), [], 'no card from an unverified page is shown');
+ });
+});
+
+test('callers that ask for the list together share one request, and a refresh waits for it then asks again', async () => {
+ const firstPage = deferred();
+ let reads = 0;
+ await withServer(async (call) => {
+ if (call.kind !== 'list') return undefined;
+ reads += 1;
+ if (reads === 1) await firstPage.promise;
+ return json({ pending_actions: [action(`a${reads}`)] });
+ }, async (server) => {
+ const store = newStore();
+ const first = stateOf(store).loadList();
+ const second = stateOf(store).loadList();
+ assert.equal(first, second);
+ firstPage.resolve();
+ await Promise.all([first, second]);
+ assert.equal(server.calls.length, 1);
+
+ const running = stateOf(store).loadList();
+ const refreshed = stateOf(store).refreshList();
+ await Promise.all([running, refreshed]);
+ assert.equal(server.calls.length, 3, 'the refresh read the first page again so nothing newer was missed');
+ assert.deepEqual(Object.keys(stateOf(store).entries).sort(), ['a1', 'a2', 'a3']);
+ });
+});
+
+test('an answer for a conversation the person has already left is dropped', async () => {
+ for (const honorAbort of [true, false]) {
+ const held = deferred();
+ await withServer(async (call) => {
+ if (call.kind !== 'list') return undefined;
+ await held.promise;
+ return json({ pending_actions: [action('late')] });
+ }, async (server) => {
+ const store = newStore();
+ const loading = stateOf(store).loadList();
+ stateOf(store).setConversation('conv-2');
+ held.resolve();
+ await loading;
+ await settled(() => server.calls.length === 1, 'the original request');
+ assert.deepEqual(Object.keys(stateOf(store).entries), [], `the late page was ignored (aborted requests: ${honorAbort})`);
+ assert.equal(stateOf(store).listStatus, 'idle');
+ assert.equal(stateOf(store).conversationId, 'conv-2');
+ }, { honorAbort });
+ }
+});
+
+/* A card named by id ------------------------------------------------------------------ */
+
+test('a card opened by its id is fetched, placed, and no longer reported as loading', async () => {
+ await withServer(readsFrom(new Map([['act-9', action('act-9')]])), async (server) => {
+ const store = newStore();
+ const loading = stateOf(store).loadById('act-9', { reference: { messageId: 'm-1' } });
+ assert.deepEqual(stateOf(store).detail['act-9'], { status: 'loading', message: '' });
+ const loaded = await loading;
+
+ assert.equal(loaded.id, 'act-9');
+ assert.equal(server.calls[0].query.conversation_id, 'conv-1');
+ assert.equal(stateOf(store).detail['act-9'], undefined);
+ const card = cardOf(store, 'act-9');
+ assert.equal(card.reference.messageId, 'm-1');
+ assert.equal(card.fullDetailsVersion, 'v1');
+ assert.equal(canChangeEntry(card), true);
+ });
+});
+
+test('a card that cannot be opened says why, and never loads a path the id could escape into', async () => {
+ await withServer((call) => {
+ if (call.kind !== 'get') return undefined;
+ if (call.id === 'gone') return json({ error: 'not_found', message: 'No such action.' }, 404);
+ if (call.id === 'private') return json({ error: 'forbidden', message: 'No.' }, 403);
+ return undefined;
+ }, async (server) => {
+ const store = newStore();
+ assert.equal(await stateOf(store).loadById('gone'), null);
+ assert.deepEqual(stateOf(store).detail.gone, { status: 'error', message: NOT_LOADED_MESSAGE });
+ assert.equal(await stateOf(store).loadById('private'), null);
+ assert.deepEqual(stateOf(store).detail.private, { status: 'error', message: PERMISSION_MESSAGE });
+
+ const before = server.calls.length;
+ for (const unsafe of ['a/b', '..', 'x?y=1', '']) {
+ assert.equal(await stateOf(store).loadById(unsafe), null);
+ assert.equal(stateOf(store).detail[unsafe].message, NOT_LOADED_MESSAGE);
+ }
+ assert.equal(server.calls.length, before, 'an unsafe id never reaches the server');
+ assert.deepEqual(Object.keys(stateOf(store).entries), []);
+ });
+});
+
+test('opening a card the page already holds checks it again and keeps where it was seen', async () => {
+ await withServer(readsFrom(new Map([['act-1', action('act-1', { updated_at: T2 })]])), async (server) => {
+ const store = newStore();
+ stateOf(store).remember(action('act-1'));
+ const loaded = await stateOf(store).loadById('act-1', { reference: { requestId: 'req-3' } });
+ assert.equal(loaded.updated_at, T2);
+ assert.equal(server.calls.length, 1);
+ assert.equal(cardOf(store, 'act-1').reference.requestId, 'req-3');
+ });
+});
+
+test('the Approvals page asks for an action on its own, and names the conversation only for someone else’s', async () => {
+ const held = new Map([
+ ['mine', action('mine')],
+ ['theirs', action('theirs', { viewer_is_owner: false, conversation_id: 'conv-7' })],
+ ]);
+ await withServer(readsFrom(held), async (server) => {
+ const store = createPendingActionsStore({ scope: 'detail' });
+ await stateOf(store).loadById('mine');
+ await stateOf(store).loadById('theirs');
+ assert.equal(server.calls[0].path, `${LIST_PATH}/mine`);
+ assert.deepEqual(server.calls[0].query, {});
+ assert.deepEqual(server.calls[1].query, {});
+
+ await stateOf(store).refresh('theirs');
+ assert.equal(server.calls[2].query.conversation_id, 'conv-7');
+ await stateOf(store).refresh('mine');
+ assert.deepEqual(server.calls[3].query, {});
+ });
+});
+
+/* Sending and cancelling -------------------------------------------------------------- */
+
+const SENT = { status: 'sent', updated_at: T2, can_cancel: false, can_send_now: false };
+
+/** Route reads and sends to the given responders; anything else is unexpected. */
+function submitServer({ post, get } = {}) {
+ return (call) => {
+ if (call.kind === 'post') return post?.(call);
+ if (call.kind === 'get') return get?.(call);
+ return undefined;
+ };
+}
+
+function readyStore(overrides = {}, id = 'act-1') {
+ const store = newStore();
+ stateOf(store).remember(action(id, overrides), { authoritative: true });
+ return store;
+}
+
+test('Send submits the version that was reviewed, once, with the Microsoft 365 safeguards', async () => {
+ await withServer(submitServer({ post: () => json({ pending_action: action('act-1', SENT) }) }), async (server) => {
+ const store = readyStore();
+ const sending = stateOf(store).submit('act-1', 'send-now');
+ assert.equal(cardOf(store, 'act-1').busy, true);
+ assert.equal(cardOf(store, 'act-1').notice.text, 'Submitting this saved action…');
+ assert.equal(canChangeEntry(cardOf(store, 'act-1')), false, 'a second click cannot send it again');
+ await sending;
+
+ assert.equal(server.calls.length, 1);
+ const post = server.calls[0];
+ assert.equal(post.path, `${LIST_PATH}/act-1/send-now`);
+ assert.deepEqual(post.body, { expected_version: 'v1' });
+ assert.equal(post.headers.get('X-Requested-With'), 'XMLHttpRequest');
+ assert.equal(post.headers.get('X-M365-CSRF-Token'), 'c'.repeat(40));
+ const before = server.all[server.all.indexOf(post) - 1];
+ assert.equal(before.path, '/api/m365/preferences', 'a fresh token is fetched right before the request');
+
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.action.status, 'sent');
+ assert.equal(card.busy, false);
+ assert.equal(card.notice.text, 'Send request checked. Review the server status and delivery note below.');
+ assert.equal(card.notice.tone, 'info');
+
+ await stateOf(store).submit('act-1', 'send-now');
+ assert.equal(server.calls.length, 1, 'an action that went out cannot be sent again');
+ });
+});
+
+test('an action that needs the approve route is submitted there, and a cancel goes to its own route', async () => {
+ await withServer(submitServer({ post: () => json({ pending_action: action('act-1', { status: 'cancelled', updated_at: T2, can_cancel: false }) }) }), async (server) => {
+ const approve = readyStore({ can_send_now: false, can_approve: true });
+ // The person's choice picks the route; the card only offers the one the server allows.
+ const sending = stateOf(approve).submit('act-1', 'approve');
+ assert.equal(cardOf(approve, 'act-1').busy, true);
+ await sending;
+ assert.equal(server.calls[0].path, `${LIST_PATH}/act-1/approve`);
+
+ const cancel = readyStore();
+ const cancelling = stateOf(cancel).submit('act-1', 'cancel');
+ assert.equal(cardOf(cancel, 'act-1').notice.text, 'Cancelling this saved action…');
+ await cancelling;
+ assert.equal(server.calls[1].path, `${LIST_PATH}/act-1/cancel`);
+ assert.deepEqual(server.calls[1].body, { expected_version: 'v1' });
+ assert.equal(
+ cardOf(cancel, 'act-1').notice.text,
+ 'Cancellation checked. The server status below is authoritative; this does not recall an already sent item.',
+ );
+ assert.equal(cardOf(cancel, 'act-1').action.status, 'cancelled');
+ });
+});
+
+test('nothing is sent while something stands between the person and a reviewed, current copy', async () => {
+ const blockedSends = [
+ ['a copy the server has not confirmed', null, (store) =>
+ stateOf(store).ingestMessages([
+ message('m-1', { m365_pending_actions: [action('act-1', { review_details_required: true })] }),
+ ])],
+ ['someone else’s action', { viewer_is_owner: false }],
+ ['an action with no way to send it', { can_send_now: false, can_approve: false }],
+ ['an action whose full content was not reviewed', { review_details_required: true }],
+ ['an action whose body is only a preview', { summary: { subject: 'S', body_preview: 'Hi', body_preview_truncated: true } }],
+ ['an action waiting for sign-in', { auth_required: true, sources: ['email'] }],
+ ['an action that already went out', { status: 'sent', can_send_now: false, can_cancel: false }],
+ ['an action that was cancelled', { status: 'cancelled' }],
+ ];
+ const blockedCancels = [
+ ['an action that cannot be cancelled', { can_cancel: false }],
+ ['someone else’s action', { viewer_is_owner: false }],
+ ['an action that was cancelled already', { status: 'cancelled' }],
+ ];
+ await withServer(() => undefined, async (server) => {
+ for (const [name, overrides, prepare] of blockedSends) {
+ const store = newStore();
+ if (prepare) prepare(store);
+ else stateOf(store).remember(action('act-1', overrides), { authoritative: true });
+ await stateOf(store).submit('act-1', 'send-now');
+ assert.equal(cardOf(store, 'act-1').busy, false, name);
+ }
+ for (const [name, overrides] of blockedCancels) {
+ const store = readyStore(overrides);
+ await stateOf(store).submit('act-1', 'cancel');
+ assert.equal(cardOf(store, 'act-1').busy, false, name);
+ }
+ assert.equal(server.calls.length, 0, 'none of these may reach the server');
+ });
+});
+
+test('cancelling stays possible when only sending is blocked', async () => {
+ const blocked = {
+ 'waiting for sign-in': { auth_required: true, sources: ['email'] },
+ 'content not reviewed': { review_details_required: true },
+ 'no way to send it': { can_send_now: false, can_approve: false },
+ };
+ await withServer(submitServer({ post: () => json({ pending_action: action('act-1', { status: 'cancelled', updated_at: T2 }) }) }), async (server) => {
+ for (const [name, overrides] of Object.entries(blocked)) {
+ const before = server.calls.length;
+ const store = readyStore(overrides);
+ await stateOf(store).submit('act-1', 'cancel');
+ assert.equal(server.calls.length, before + 1, name);
+ assert.equal(server.calls[before].operation, 'cancel', name);
+ }
+ });
+});
+
+test('a second click while a send is in flight does not send twice', async () => {
+ const gate = deferred();
+ await withServer(submitServer({ post: async () => {
+ await gate.promise;
+ return json({ pending_action: action('act-1', SENT) });
+ } }), async (server) => {
+ const store = readyStore();
+ const first = stateOf(store).submit('act-1', 'send-now');
+ const second = stateOf(store).submit('act-1', 'send-now');
+ const cancel = stateOf(store).submit('act-1', 'cancel');
+ gate.resolve();
+ await Promise.all([first, second, cancel]);
+ assert.equal(server.calls.length, 1);
+ });
+});
+
+test('a conflict makes the person look again; it never retries on their behalf', async () => {
+ await withServer(submitServer({
+ post: () => json({ error: 'conflict', message: 'Changed.' }, 409),
+ get: () => json({ pending_action: action('act-1', { version: 'v2', updated_at: T2, subject: 'Edited elsewhere' }) }),
+ }), async (server) => {
+ const store = readyStore();
+ await stateOf(store).submit('act-1', 'send-now');
+ assert.deepEqual(server.calls.map((call) => call.kind), ['post', 'get']);
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.action.version, 'v2');
+ assert.equal(card.action.subject, 'Edited elsewhere');
+ assert.equal(card.fullDetailsVersion, 'v2');
+ assert.equal(
+ card.notice.text,
+ 'This action changed or is already being processed. Review its current status and details before making another choice.',
+ );
+ assert.equal(card.busy, false);
+ assert.equal(card.needsRefresh, false);
+ });
+
+ await withServer(submitServer({
+ post: () =>
+ json({
+ error: 'conflict',
+ message: 'Already being sent.',
+ pending_action: action('act-1', { version: 'v2', status: 'sending', updated_at: T2, can_cancel: false, can_send_now: false }),
+ }, 409),
+ }), async (server) => {
+ const store = readyStore();
+ await stateOf(store).submit('act-1', 'send-now');
+ assert.deepEqual(server.calls.map((call) => call.kind), ['post'], 'the conflict already carried the current state');
+ assert.equal(cardOf(store, 'act-1').action.status, 'sending');
+ assert.equal(cardOf(store, 'act-1').notice.tone, 'warn');
+ });
+});
+
+test('a response nobody could confirm is never retried; the server is asked what became of it', async () => {
+ const unconfirmed = {
+ 'a server error': () => json({ error: 'upstream_down', message: 'Down.' }, 500),
+ 'a refusal inside a success response': () => json({ success: false, message: 'Declined.' }),
+ 'a success response without the action': () => json({ ok: true }),
+ };
+ for (const [name, respond] of Object.entries(unconfirmed)) {
+ await withServer(submitServer({
+ post: respond,
+ get: () => json({ pending_action: action('act-1', { updated_at: T2 }) }),
+ }), async (server) => {
+ const store = readyStore();
+ const sending = stateOf(store).submit('act-1', 'send-now');
+ await sending;
+ assert.deepEqual(server.calls.map((call) => call.kind), ['post', 'get'], name);
+ const card = cardOf(store, 'act-1');
+ assert.equal(
+ card.notice.text,
+ 'The response was not confirmed. Current server status has been refreshed. Review it before making another choice; no send was retried automatically.',
+ name,
+ );
+ assert.equal(card.needsRefresh, false, name);
+ assert.equal(card.busy, false, name);
+ });
+ }
+});
+
+test('when the status cannot be checked after an unconfirmed response the card stays blocked', async () => {
+ await withServer(submitServer({
+ post: () => json({ error: 'upstream_down', message: 'Down.' }, 500),
+ get: () => json({ error: 'upstream_down', message: 'Still down.' }, 500),
+ }), async (server) => {
+ const store = readyStore();
+ await stateOf(store).submit('act-1', 'send-now');
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.needsRefresh, true);
+ assert.equal(card.notice.text, 'The current action status could not be checked. Refresh status before trying again.');
+ assert.equal(canChangeEntry(card), false);
+
+ await stateOf(store).submit('act-1', 'send-now');
+ await stateOf(store).submit('act-1', 'cancel');
+ assert.equal(server.calls.length, 2, 'neither choice reaches the server until the status is known');
+ });
+});
+
+test('a refusal blocks the card until its access has been checked again', async () => {
+ let refused = true;
+ await withServer(submitServer({
+ post: () => json({ error: 'forbidden', message: 'No.' }, 403),
+ get: () => (refused ? undefined : json({ pending_action: action('act-1', { updated_at: T2 }) })),
+ }), async (server) => {
+ const store = readyStore();
+ await stateOf(store).submit('act-1', 'send-now');
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.denied, true);
+ assert.equal(card.notice.text, 'You do not have permission to change this action. Refresh status to check your current access.');
+ assert.equal(canChangeEntry(card), false);
+ assert.deepEqual(server.calls.map((call) => call.kind), ['post']);
+
+ refused = false;
+ await stateOf(store).refresh('act-1');
+ assert.equal(cardOf(store, 'act-1').denied, false);
+ assert.equal(canChangeEntry(cardOf(store, 'act-1')), true);
+ });
+});
+
+test('a send that needs a Microsoft 365 sign-in is held until the person reconnects, but can still be cancelled', async () => {
+ const needsSignIn = {
+ error: 'auth_required',
+ auth_required: true,
+ message: 'Sign in again.',
+ sources: ['email'],
+ scopes: ['Mail.Send'],
+ };
+ let respond = () => json(needsSignIn, 401);
+ await withServer(submitServer({ post: () => respond() }), async (server) => {
+ const store = readyStore();
+ await stateOf(store).submit('act-1', 'send-now');
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.auth.auth_required, true);
+ assert.deepEqual(card.auth.sources, ['email']);
+ assert.equal(
+ card.notice.text,
+ 'Microsoft 365 sign-in is required. Reconnect, review this same saved action, then select Send again. Signing in does not send it.',
+ );
+ assert.deepEqual(server.calls.map((call) => call.kind), ['post'], 'a sign-in prompt is not a failure to retry');
+
+ await stateOf(store).submit('act-1', 'send-now');
+ assert.equal(server.calls.length, 1, 'sending is blocked until the person has reconnected');
+
+ respond = () => json({ pending_action: action('act-1', { status: 'cancelled', updated_at: T2 }) });
+ await stateOf(store).submit('act-1', 'cancel');
+ assert.equal(server.calls.length, 2);
+ assert.equal(server.calls[1].operation, 'cancel');
+ assert.equal(cardOf(store, 'act-1').auth, null, 'a cancel that went through ends the sign-in prompt');
+ });
+});
+
+test('a send that needs a sharing decision is held until the server’s copy says otherwise', async () => {
+ let respond = () =>
+ json({ error: 'approval_required', approval_required: true, message: 'Approve sharing first.', approval_id: 'ap-1' }, 409);
+ await withServer(submitServer({
+ post: () => respond(),
+ get: () => json({ pending_action: action('act-1', { updated_at: T2 }) }),
+ }), async (server) => {
+ const store = readyStore();
+ await stateOf(store).submit('act-1', 'send-now');
+ const card = cardOf(store, 'act-1');
+ assert.equal(card.approvalRequired.approval_id, 'ap-1');
+ assert.equal(
+ card.notice.text,
+ 'Review the saved Microsoft 365 sharing decision before sending. Approving it does not send this action; return here and select Send again.',
+ );
+ assert.deepEqual(server.calls.map((call) => call.kind), ['post'], 'the sharing decision is made elsewhere; nothing is retried');
+
+ await stateOf(store).submit('act-1', 'send-now');
+ assert.equal(server.calls.length, 1, 'sending is blocked while the decision is outstanding');
+
+ // Coming back from the Approvals page reads the card again; the server decides whether to send.
+ await stateOf(store).refresh('act-1');
+ assert.equal(cardOf(store, 'act-1').approvalRequired, null);
+ respond = () => json({ pending_action: action('act-1', SENT) });
+ await stateOf(store).submit('act-1', 'send-now');
+ assert.equal(server.calls.filter((call) => call.kind === 'post').length, 2);
+ assert.equal(cardOf(store, 'act-1').action.status, 'sent');
+ });
+});
+
+test('a send does not outlive the conversation it was made in', async () => {
+ const gate = deferred();
+ await withServer(submitServer({ post: async () => {
+ await gate.promise;
+ return json({ pending_action: action('act-1', SENT) });
+ } }), async (server) => {
+ const store = readyStore();
+ const sending = stateOf(store).submit('act-1', 'send-now');
+ await settled(() => server.calls.length === 1, 'the send request');
+ stateOf(store).setConversation('conv-2');
+ gate.resolve();
+ await sending;
+ assert.deepEqual(Object.keys(stateOf(store).entries), [], 'the answer belongs to a conversation that is no longer open');
+ });
+});
+
+/* Reviewing the full content -------------------------------------------------------------- */
+
+test('opening a card’s body checks the version being read, once', async () => {
+ await withServer(readsFrom(new Map([['act-1', action('act-1', { updated_at: T2 })]])), async (server) => {
+ const store = readyStore();
+ stateOf(store).setBodyOpen('act-1', true);
+ assert.equal(cardOf(store, 'act-1').bodyOpen, true);
+ assert.equal(cardOf(store, 'act-1').detailVersion, 'v1');
+ await stateOf(store).refresh('act-1');
+ assert.equal(server.calls.length, 1);
+
+ stateOf(store).setBodyOpen('act-1', false);
+ stateOf(store).setBodyOpen('act-1', true);
+ assert.equal(server.calls.length, 1, 'the same version is not checked every time the body is opened');
+
+ stateOf(store).remember(action('act-1', { version: 'v2', updated_at: T3 }));
+ stateOf(store).setBodyOpen('act-1', false);
+ stateOf(store).setBodyOpen('act-1', true);
+ await stateOf(store).refresh('act-1');
+ assert.equal(server.calls.length, 2, 'a new version is a new thing to read');
+ });
+});
+
+test('a full review that cannot be completed leaves the card blocked and says so', async () => {
+ let respond = () => json({ error: 'upstream_down', message: 'Down.' }, 500);
+ await withServer(submitServer({ get: () => respond() }), async (server) => {
+ const store = readyStore({ review_details_required: true });
+ await stateOf(store).reviewFull('act-1');
+ let card = cardOf(store, 'act-1');
+ assert.equal(card.reviewing, false);
+ assert.equal(card.bodyOpen, false);
+ assert.equal(card.needsRefresh, true);
+ assert.equal(canChangeEntry(card), false);
+ assert.equal(card.notice.text, 'The current action status could not be checked. Refresh status before trying again.');
+
+ respond = () => json({ pending_action: action('act-1', { review_details_required: true, updated_at: T2 }) });
+ await stateOf(store).reviewFull('act-1');
+ card = cardOf(store, 'act-1');
+ assert.equal(card.reviewing, false);
+ assert.equal(card.needsRefresh, true, 'a server copy that still withholds the content is not a review');
+ assert.equal(card.notice.text, 'The complete saved content could not be verified. Review full details again before sending.');
+ assert.equal(server.calls.length, 2);
+ });
+});
+
+/* Scheduled sends --------------------------------------------------------------------- */
+
+test('a scheduled action is checked once its time has come, not on every tick', async () => {
+ const due = new Date(Date.now() - 60_000).toISOString();
+ const later = new Date(Date.now() + 3_600_000).toISOString();
+ const stillScheduled = action('due', { status: 'scheduled', will_auto_send: true, auto_send_at_utc: due, updated_at: T2 });
+ await withServer(readsFrom(new Map([['due', stillScheduled]])), async (server) => {
+ const store = newStore();
+ stateOf(store).remember(action('due', { status: 'scheduled', will_auto_send: true, auto_send_at_utc: due }), { authoritative: true });
+ stateOf(store).remember(action('later', { status: 'scheduled', will_auto_send: true, auto_send_at_utc: later }), { authoritative: true });
+
+ stateOf(store).pollIfDue('later');
+ stateOf(store).pollIfDue('missing');
+ assert.equal(server.calls.length, 0, 'it is not time yet');
+
+ stateOf(store).pollIfDue('due');
+ stateOf(store).pollIfDue('due');
+ await stateOf(store).refresh('due');
+ assert.equal(server.calls.length, 1, 'one check serves every tick that arrives while it runs');
+ assert.equal(cardOf(store, 'due').action.updated_at, T2, 'the answer was taken');
+ assert.ok(cardOf(store, 'due').nextRefreshAt > Date.now(), 'the next check waits a while');
+
+ stateOf(store).pollIfDue('due');
+ assert.equal(server.calls.length, 1, 'the server has not sent it yet, but asking again straight away would not help');
+ });
+});
+
+/* Rules about when a card can be changed -------------------------------------------- */
+
+test('a card can be changed only when nothing is in the way', () => {
+ const store = newStore();
+ const base = stateOf(store).remember(action('act-1'), { authoritative: true });
+ assert.equal(canChangeEntry(base), true);
+
+ const inTheWay = {
+ sending: { busy: true },
+ refreshing: { refreshing: true },
+ unverified: { needsRefresh: true },
+ denied: { denied: true },
+ 'someone else’s': { action: { ...base.action, viewer_is_owner: false } },
+ unversioned: { action: { ...base.action, version: '' } },
+ 'no version at all': { action: { ...base.action, version: undefined } },
+ };
+ for (const [name, patch] of Object.entries(inTheWay)) {
+ assert.equal(canChangeEntry({ ...base, ...patch }), false, name);
+ }
+ const unknownOwner = { ...base.action };
+ delete unknownOwner.viewer_is_owner;
+ assert.equal(canChangeEntry({ ...base, action: unknownOwner }), true, 'an action that does not say otherwise is the viewer’s own');
+
+ for (const status of ['pending', 'scheduled', 'review_required']) {
+ assert.equal(canChangeEntry({ ...base, action: { ...base.action, status } }), true, status);
+ }
+ for (const status of ['sending', 'sent', 'cancelled', 'failed', 'expired', 'unknown']) {
+ assert.equal(canChangeEntry({ ...base, action: { ...base.action, status } }), false, status);
+ }
+});
+
+/* Focus requests from a notification -------------------------------------------------- */
+
+test('a notification can ask for a card before its conversation has finished opening', () => {
+ const store = newStore({ conversationId: '' });
+ stateOf(store).requestFocus('act-1', ' conv-9 ');
+ const first = stateOf(store).focusRequest;
+ assert.deepEqual({ id: first.id, conversationId: first.conversationId }, { id: 'act-1', conversationId: 'conv-9' });
+
+ stateOf(store).requestFocus('act-1', 'conv-9');
+ assert.notEqual(stateOf(store).focusRequest.key, first.key, 'asking again is a new request');
+
+ for (const unsafe of ['a/b', '..', 'x?y', '', ' ', 'a\nb']) {
+ const before = stateOf(store).focusRequest;
+ stateOf(store).requestFocus(unsafe, 'conv-9');
+ assert.equal(stateOf(store).focusRequest, before, `${JSON.stringify(unsafe)} is not a card id`);
+ }
+
+ stateOf(store).setConversation('conv-9');
+ assert.equal(stateOf(store).focusRequest.id, 'act-1', 'the request survives the switch it was waiting for');
+ stateOf(store).setConversation('conv-10');
+ assert.equal(stateOf(store).focusRequest, null, 'a request for another conversation is dropped');
+
+ stateOf(store).requestFocus('act-2', 'conv-10');
+ stateOf(store).clearFocus();
+ assert.equal(stateOf(store).focusRequest, null);
+ stateOf(store).clearFocus();
+});
+
+test('leaving a conversation forgets its cards, its place in the list and what it was waiting for', async () => {
+ await withServer(() => json({ pending_actions: [action('a1')], continuation_token: 'tok' }), async () => {
+ const store = newStore();
+ await stateOf(store).loadList();
+ stateOf(store).setLiveStream({ conversationId: 'conv-1', userMessageId: 'u-1' });
+ stateOf(store).requestFocus('a1', 'conv-1');
+ assert.equal(stateOf(store).continuationToken, 'tok');
+
+ const epoch = stateOf(store).epoch;
+ stateOf(store).setConversation('conv-1');
+ assert.equal(stateOf(store).epoch, epoch, 'opening the conversation that is already open changes nothing');
+
+ stateOf(store).setConversation('conv-2');
+ const state = stateOf(store);
+ assert.equal(state.epoch, epoch + 1);
+ assert.deepEqual(
+ { entries: state.entries, status: state.listStatus, token: state.continuationToken, live: state.liveStream, focus: state.focusRequest },
+ { entries: {}, status: 'idle', token: '', live: null, focus: null },
+ );
+
+ stateOf(store).setConversation('conv-3');
+ stateOf(store).requestFocus('a1', 'conv-3');
+ stateOf(store).reset();
+ assert.deepEqual(
+ { conversation: stateOf(store).conversationId, focus: stateOf(store).focusRequest, epoch: stateOf(store).epoch },
+ { conversation: '', focus: null, epoch: epoch + 3 },
+ );
+ });
+});
+
+/* Cards that arrive with a reply --------------------------------------------------------- */
+
+test('a message with nothing to do with outgoing actions is passed over', () => {
+ const store = newStore();
+ stateOf(store).ingestMessages([message('m-0'), null, 'text', { content: 'no id', m365_pending_actions: [action('act-1')] }]);
+ stateOf(store).trackMessage(null);
+ stateOf(store).trackMessage('text');
+ assert.deepEqual(Object.keys(stateOf(store).entries), []);
+ assert.equal(stateOf(store).referenceLoading, false);
+});
+
+test('cards on a live stream are drawn under the turn that caused them, and move to the reply once it is saved', () => {
+ const store = newStore();
+ const frame = { type: 'm365_pending_action', pending_action: action('s-1') };
+ stateOf(store).handleStreamPayload(frame, { conversationId: 'conv-1', userMessageId: 'u-1' });
+ let card = cardOf(store, 's-1');
+ assert.deepEqual(
+ { ...card.reference },
+ { messageId: '', userMessageId: 'u-1', fallbackMessageId: 'u-1', requestId: '' },
+ );
+ assert.equal(card.needsRefresh, false, 'a live frame is as fresh as the server can make it');
+
+ stateOf(store).handleStreamPayload(
+ { type: 'done', message_id: 'a-1', user_message_id: 'u-1', request_id: 'req-1', m365_pending_actions: [action('s-1')] },
+ { conversationId: 'conv-1', userMessageId: 'u-1' },
+ );
+ card = cardOf(store, 's-1');
+ assert.equal(card.reference.messageId, 'a-1');
+ assert.equal(card.reference.requestId, 'req-1');
+ assert.equal(card.reference.userMessageId, 'u-1');
+});
+
+test('a stream frame for another conversation, or none, is not drawn', () => {
+ const none = newStore({ conversationId: '' });
+ stateOf(none).handleStreamPayload({ pending_action: action('s-1') }, { conversationId: 'conv-1' });
+ assert.deepEqual(Object.keys(stateOf(none).entries), []);
+
+ const store = newStore();
+ stateOf(store).handleStreamPayload({ pending_action: action('s-1') }, { conversationId: 'conv-2' });
+ assert.deepEqual(Object.keys(stateOf(store).entries), []);
+ stateOf(store).handleStreamPayload({ pending_action: action('s-1') });
+ assert.deepEqual(Object.keys(stateOf(store).entries), ['s-1'], 'a frame that names no conversation belongs to the one open');
+});
+
+test('cards that were saved but could not be built are recovered from the list', async () => {
+ await withServer((call) => (call.kind === 'list' ? json({ pending_actions: [action('saved')] }) : undefined), async (server) => {
+ const store = newStore();
+ stateOf(store).handleStreamPayload(
+ { m365_pending_actions_error: { message: 'The cards could not be built.' } },
+ { conversationId: 'conv-1' },
+ );
+ assert.equal(stateOf(store).referenceError, 'The cards could not be built.');
+ assert.equal(stateOf(store).listStatus, 'loading', 'the list is the way back to what was saved');
+ await settled(() => stateOf(store).listStatus === 'loaded', 'the list');
+ assert.equal(server.calls.length, 1);
+ assert.deepEqual(Object.keys(stateOf(store).entries), ['saved']);
+ assert.equal(stateOf(store).referenceError, '');
+ });
+
+ await withServer(() => json({ error: 'upstream_down', message: 'Down.' }, 500), async () => {
+ const store = newStore();
+ stateOf(store).handleStreamPayload({ m365_pending_actions_error: { error: 'm365_pending_actions_unavailable' } }, { conversationId: 'conv-1' });
+ await settled(() => stateOf(store).listStatus === 'error', 'the failed list');
+ assert.equal(
+ stateOf(store).referenceError,
+ 'Microsoft 365 actions were saved, but their cards could not be loaded. Reload the conversation to recover them. Do not repeat the request.',
+ 'the person is still told, since the list did not help',
+ );
+ });
+});
+
+test('when the saved id of the turn replaces its temporary one, its cards follow', () => {
+ const store = newStore();
+ stateOf(store).setLiveStream({ conversationId: 'conv-1', userMessageId: 'tmp-1' });
+ stateOf(store).handleStreamPayload({ pending_action: action('mine') }, { conversationId: 'conv-1', userMessageId: 'tmp-1' });
+ stateOf(store).handleStreamPayload({ pending_action: action('other') }, { conversationId: 'conv-1', userMessageId: 'tmp-0' });
+ const untouched = cardOf(store, 'other');
+
+ const same = stateOf(store);
+ stateOf(store).setLiveStream({ conversationId: 'conv-1', userMessageId: 'tmp-1' });
+ assert.equal(stateOf(store), same, 'announcing the same turn again changes nothing');
+
+ stateOf(store).setLiveStream({ conversationId: 'conv-1', userMessageId: 'u-9' });
+ assert.deepEqual(stateOf(store).liveStream, { conversationId: 'conv-1', userMessageId: 'u-9' });
+ assert.equal(cardOf(store, 'mine').reference.userMessageId, 'u-9');
+ assert.equal(cardOf(store, 'mine').reference.fallbackMessageId, 'u-9');
+ assert.equal(cardOf(store, 'other'), untouched, 'another turn’s card is left as it was');
+
+ stateOf(store).setLiveStream({ conversationId: 'conv-2', userMessageId: 'x-1' });
+ assert.equal(cardOf(store, 'mine').reference.userMessageId, 'u-9', 'a different conversation renames nothing');
+
+ stateOf(store).setLiveStream(null);
+ assert.equal(stateOf(store).liveStream, null);
+ const quiet = stateOf(store);
+ stateOf(store).setLiveStream(null);
+ assert.equal(stateOf(store), quiet);
+});
diff --git a/functional_tests/test_v2_m365_pending_actions_stream.mjs b/functional_tests/test_v2_m365_pending_actions_stream.mjs
new file mode 100644
index 000000000..8214309e0
--- /dev/null
+++ b/functional_tests/test_v2_m365_pending_actions_stream.mjs
@@ -0,0 +1,1002 @@
+// test_v2_m365_pending_actions_stream.mjs
+// Version: 0.261.307
+// Implemented in: 0.261.307
+// Executes the live paths that put a Microsoft 365 outgoing-action card in front of someone in V2
+// chat: the notification hand-off that asks the page to bring a card into view, the SSE reader that
+// gives saved actions to the chat store ahead of the answer text, a real send whose stream is held
+// open and fed server-shaped frames so the card's position can be read at every step (under the
+// turn while the reply streams, under the reply once it exists), and the shared-conversation events
+// that tell every participant to read the list again. Only HTTP and EventSource are simulated; the
+// chat store, the pending-action store and the stream and event readers are the real ones.
+
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import './test_support/tsResolve.mjs';
+
+// The repository resolver must be registered before extensionless TypeScript imports load.
+const { useChatStore } = await import('../application/v2_ui/src/stores/chatStore.ts');
+const { chatPendingActionsStore } = await import('../application/v2_ui/src/stores/m365PendingActionsStore.ts');
+const { carriesPendingActions, streamChat } = await import('../application/v2_ui/src/lib/sse.ts');
+const { dispatchCollaborationEvent, subscribeToCollaborationEvents } = await import(
+ '../application/v2_ui/src/lib/collaborationEvents.ts'
+);
+const { openConversationFromNotification, openNotificationTarget } = await import(
+ '../application/v2_ui/src/lib/notificationNavigation.ts'
+);
+const { chatHrefForConversation, chatHrefForPendingAction } = await import(
+ '../application/v2_ui/src/lib/conversationUrl.ts'
+);
+const { normalizeNotification } = await import('../application/v2_ui/src/lib/notifications.ts');
+const { resolveNotificationLink } = await import('../application/v2_ui/src/lib/notificationLinks.ts');
+const {
+ ORPHAN_ANCHOR,
+ PENDING_ACTIONS_UNAVAILABLE_MESSAGE,
+ STREAMING_ANCHOR,
+ lastMessageByRequestId,
+ resolveAnchor,
+} = await import('../application/v2_ui/src/lib/m365PendingActions.ts');
+
+const ORIGIN = 'https://simplechat.test';
+const STREAM_PATH = '/api/chat/stream';
+const LIST_PATH = '/api/msgraph/pending-actions';
+const T1 = '2026-05-01T10:00:00Z';
+const T2 = '2026-05-01T10:05:00Z';
+
+const chat = () => useChatStore.getState();
+const store = () => chatPendingActionsStore.getState();
+
+/** A saved outgoing action as the server serializes it. */
+function action(id = 'act-1', overrides = {}) {
+ return {
+ type: 'msgraph_pending_action',
+ id,
+ version: 'v1',
+ status: 'pending',
+ operation: 'send_mail',
+ graph_resource_type: 'mail',
+ subject: 'Quarterly numbers',
+ summary: { subject: 'Quarterly numbers', body_preview: 'Hello', body_preview_truncated: false },
+ can_cancel: true,
+ can_send_now: true,
+ viewer_is_owner: true,
+ review_details_required: false,
+ updated_at: T1,
+ ...overrides,
+ };
+}
+
+function json(body, status = 200) {
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+}
+
+function deferred() {
+ let resolve;
+ let reject;
+ const promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+ });
+ return { promise, resolve, reject };
+}
+
+/** Wait, a turn of the event loop at a time, until the store has caught up with what was fed to it. */
+async function settled(condition, description = 'the condition') {
+ for (let turn = 0; turn < 500; turn += 1) {
+ if (condition()) return;
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ }
+ throw new Error(`${description} was never met`);
+}
+
+const requestUrl = (input) =>
+ new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url, 'http://localhost');
+
+function resetStores() {
+ useChatStore.setState({ ...useChatStore.getInitialState() }, true);
+ store().reset();
+}
+
+/**
+ * Open a conversation the way the chat page does.
+ *
+ * Both stores are reset first and the conversation is set on a clean state, because the chat
+ * store only tells the pending-action store about a conversation when its id changes: reopening
+ * the id a previous test left behind would leave the pending-action store with no conversation
+ * and every frame for it would be dropped.
+ */
+function openConversation(conversationId, extra = {}) {
+ resetStores();
+ useChatStore.setState(
+ {
+ ...useChatStore.getInitialState(),
+ activeConversationId: conversationId,
+ activeConversationKind: 'personal',
+ conversations: [{ id: conversationId, title: 'Quarterly comparison' }],
+ ...extra,
+ },
+ true,
+ );
+}
+
+/** Where the thread draws a saved action: the same question the chat page asks for every card. */
+function anchorOf(id) {
+ const pending = store();
+ const entry = pending.entries[id];
+ assert.ok(entry, `${id} is not a card in the store`);
+ const anchorable = chat().messages.filter((message) => !['image', 'file', 'safety'].includes(message.role));
+ const live = pending.liveStream;
+ return resolveAnchor(entry.reference, entry.action, {
+ visibleMessageIds: new Set(anchorable.map((message) => message.id)),
+ lastMessageByRequestId: lastMessageByRequestId(anchorable),
+ streamingUserMessageId: live && live.conversationId === pending.conversationId ? live.userMessageId : '',
+ });
+}
+
+/**
+ * Replace fetch with streams the test feeds by hand.
+ *
+ * Every request opens a held stream keyed by its path, so each intermediate state can be read
+ * before the next frame lands. A path the test never feeds simply stays pending. A path listed
+ * in `answers` gets that JSON at once instead, for the requests a stream makes on the side.
+ * Every request is recorded, in order, in `requests`. As with a real fetch, aborting a request
+ * fails the body its reader is still waiting on, so a reply the reader stops stops being read.
+ */
+function holdStreams(answers = {}) {
+ const encoder = new TextEncoder();
+ const streams = new Map();
+ const waiters = new Map();
+ const requests = [];
+ const original = globalThis.fetch;
+ globalThis.fetch = async (input, init = {}) => {
+ const url = requestUrl(input);
+ const path = url.pathname;
+ requests.push({ method: init.method ?? 'GET', path, query: Object.fromEntries(url.searchParams) });
+ if (Object.hasOwn(answers, path)) return json(answers[path]);
+ const body = new ReadableStream({
+ start(controller) {
+ streams.set(path, controller);
+ init.signal?.addEventListener(
+ 'abort',
+ () => {
+ try {
+ controller.error(new DOMException('The operation was aborted.', 'AbortError'));
+ } catch {
+ // The stream had already ended.
+ }
+ },
+ { once: true },
+ );
+ },
+ });
+ const waiter = waiters.get(path);
+ if (waiter) {
+ waiters.delete(path);
+ waiter(typeof init.body === 'string' ? JSON.parse(init.body) : null);
+ }
+ return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
+ };
+ return {
+ requests,
+ listRequests: () => requests.filter((request) => request.path === LIST_PATH),
+ opened(path) {
+ return new Promise((resolve) => waiters.set(path, resolve));
+ },
+ emit(path, event) {
+ streams.get(path).enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ },
+ end(path) {
+ try {
+ streams.get(path)?.close();
+ } catch {
+ // The reader already stopped at the terminal frame.
+ }
+ },
+ restore() {
+ globalThis.fetch = original;
+ },
+ };
+}
+
+/** Run a test against a real send in `conversationId` whose reply stream the test controls. */
+async function withLiveReply(conversationId, run, { answers = {}, text = 'Email Dana the numbers' } = {}) {
+ openConversation(conversationId);
+ const streams = holdStreams(answers);
+ try {
+ const opened = streams.opened(STREAM_PATH);
+ void chat().sendMessage(text, {});
+ const body = await opened;
+ await run({ streams, body, userMessageId: store().liveStream?.userMessageId ?? '' });
+ } finally {
+ streams.end(STREAM_PATH);
+ streams.restore();
+ resetStores();
+ }
+}
+
+const creationFrame = (id = 'act-1', overrides = {}) => ({
+ type: 'm365_pending_action',
+ pending_action: action(id),
+ conversation_id: 'conv-1',
+ request_id: 'req-1',
+ ...overrides,
+});
+
+const replyFinished = () => chat().streaming === false && store().liveStream === null;
+
+/** The body of a finished SSE response, as the server frames it. */
+function sseResponse(frames) {
+ const encoder = new TextEncoder();
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ for (const frame of frames) controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
+ controller.close();
+ },
+ }),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+}
+
+async function withFetch(fake, run) {
+ const original = globalThis.fetch;
+ globalThis.fetch = fake;
+ try {
+ return await run();
+ } finally {
+ globalThis.fetch = original;
+ }
+}
+
+/* Following a notice about a saved action ---------------------------------------------- */
+
+function navigation(pathname) {
+ const visited = [];
+ return { visited, context: { navigate: (path) => visited.push(path), pathname } };
+}
+
+/** Stand in for opening a conversation, noting what had been asked for at the moment it was opened. */
+function spyOnOpen() {
+ const opened = [];
+ useChatStore.setState({
+ openLinkedConversation: async (conversationId) => {
+ const focus = store().focusRequest;
+ opened.push({
+ conversationId,
+ focus: focus ? { id: focus.id, conversationId: focus.conversationId } : null,
+ });
+ },
+ });
+ return opened;
+}
+
+function notice(link) {
+ return normalizeNotification({
+ id: 'n-1',
+ notification_type: 'system_announcement',
+ title: 'A Microsoft 365 action needs your attention',
+ message: 'An email is waiting to be sent.',
+ created_at: '2026-05-01T09:05:00Z',
+ is_read: false,
+ link_url: link,
+ link_context: {},
+ metadata: { m365_pending_action_id: 'act-1' },
+ type_config: { icon: 'bi-envelope', color: 'info' },
+ });
+}
+
+test('on the chat page the card is asked for before the conversation is opened', async () => {
+ openConversation('conv-1');
+ const opened = spyOnOpen();
+ const { visited, context } = navigation('/chat');
+ openConversationFromNotification('conv-2', context, 'act-9');
+ assert.deepEqual(opened, [{ conversationId: 'conv-2', focus: { id: 'act-9', conversationId: 'conv-2' } }]);
+ assert.deepEqual(visited, [], 'the chat page opens the conversation itself rather than navigating');
+});
+
+test('a conversation that is already open is not opened again, but the card is still asked for', () => {
+ openConversation('conv-2');
+ const opened = spyOnOpen();
+ const { visited, context } = navigation('/chat');
+ openConversationFromNotification('conv-2', context, 'act-9');
+ assert.deepEqual(opened, []);
+ assert.deepEqual(visited, []);
+ assert.equal(store().focusRequest.id, 'act-9');
+ assert.equal(store().focusRequest.conversationId, 'conv-2');
+});
+
+test('a notice with no saved action opens the conversation without asking for a card', () => {
+ openConversation('conv-1');
+ const opened = spyOnOpen();
+ openConversationFromNotification('conv-2', navigation('/chat').context);
+ assert.deepEqual(opened, [{ conversationId: 'conv-2', focus: null }]);
+ assert.equal(store().focusRequest, null);
+});
+
+test('a saved-action id that must not be carried opens the conversation without a focus request', () => {
+ for (const unsafe of ['act/../1', 'act?1', 'act#1', '..', 'x'.repeat(201)]) {
+ openConversation('conv-1');
+ const opened = spyOnOpen();
+ openConversationFromNotification('conv-2', navigation('/chat').context, unsafe);
+ assert.deepEqual(opened, [{ conversationId: 'conv-2', focus: null }], unsafe);
+ assert.equal(store().focusRequest, null, unsafe);
+ }
+});
+
+test('away from the chat page the address carries the card instead of a focus request', () => {
+ openConversation('conv-1');
+ const { visited, context } = navigation('/approvals');
+ openConversationFromNotification('conv-2', context, 'act-1');
+ openConversationFromNotification('conv-2', context);
+ assert.deepEqual(visited, [
+ '/chat?conversationId=conv-2&m365_pending_action=act-1',
+ '/chat?conversationId=conv-2',
+ ]);
+ assert.equal(visited[0], chatHrefForPendingAction('conv-2', 'act-1'));
+ assert.equal(visited[1], chatHrefForConversation('conv-2'));
+ assert.equal(store().focusRequest, null);
+});
+
+test('a conversation target follows the same hand-off and a route target is simply followed', async () => {
+ openConversation('conv-1');
+ const opened = spyOnOpen();
+ const onChat = navigation('/chat');
+ await openNotificationTarget(
+ { kind: 'conversation', conversationId: 'conv-2', pendingActionId: 'act-1' },
+ onChat.context,
+ );
+ await openNotificationTarget({ kind: 'route', path: '/approvals?status=pending' }, onChat.context);
+ assert.deepEqual(opened, [{ conversationId: 'conv-2', focus: { id: 'act-1', conversationId: 'conv-2' } }]);
+ assert.deepEqual(onChat.visited, ['/approvals?status=pending']);
+});
+
+test('a notice about a saved action, followed from any page, reaches the V2 chat with the card named', async () => {
+ const link = '/chats?conversationId=conv-2&m365_pending_action=act-1';
+ const { target, error } = resolveNotificationLink(notice(link), ORIGIN);
+ assert.equal(error, null);
+
+ openConversation('conv-1');
+ const opened = spyOnOpen();
+ const onChat = navigation('/chat');
+ await openNotificationTarget(target, onChat.context);
+ assert.deepEqual(opened, [{ conversationId: 'conv-2', focus: { id: 'act-1', conversationId: 'conv-2' } }]);
+ assert.deepEqual(onChat.visited, []);
+
+ const elsewhere = navigation('/approvals');
+ await openNotificationTarget(target, elsewhere.context);
+ assert.deepEqual(elsewhere.visited, ['/chat?conversationId=conv-2&m365_pending_action=act-1']);
+ resetStores();
+});
+
+test('the request for a card survives the switch to its conversation and no other', () => {
+ openConversation('conv-1');
+ store().requestFocus('act-1', 'conv-2');
+ useChatStore.setState({ activeConversationId: 'conv-2' });
+ assert.equal(store().focusRequest?.id, 'act-1');
+ assert.equal(store().conversationId, 'conv-2');
+
+ store().requestFocus('act-1', 'conv-3');
+ useChatStore.setState({ activeConversationId: 'conv-4' });
+ assert.equal(store().focusRequest, null, 'a request for another conversation is dropped');
+ resetStores();
+});
+
+/* Saved actions in the chat stream ----------------------------------------------------- */
+
+function recordHandlers() {
+ const log = [];
+ return {
+ log,
+ names: () => log.map(([name]) => name),
+ handlers: {
+ onM365PendingActions: (event) => log.push(['actions', event]),
+ onContent: (delta, accumulated) => log.push(['content', delta, accumulated]),
+ onDone: (event, accumulated) => log.push(['done', event, accumulated]),
+ onError: (message, event) => log.push(['error', message, event]),
+ },
+ };
+}
+
+const readStream = (fake, handlers) =>
+ withFetch(fake, () =>
+ streamChat({ message: 'Email Dana', conversation_id: 'conv-1' }, handlers, undefined, { allowRecovery: false }),
+ );
+
+test('only a frame that reports saved actions is handed to the pending-action handler', () => {
+ assert.equal(carriesPendingActions(creationFrame()), true);
+ assert.equal(carriesPendingActions({ done: true, m365_pending_actions: [] }), true);
+ assert.equal(carriesPendingActions({ error: 'Failed', m365_pending_actions_error: { message: 'Not listed' } }), true);
+ for (const frame of [null, undefined, {}, { content: 'x' }, { done: true }, { type: 'thought' }]) {
+ assert.equal(carriesPendingActions(frame), false, JSON.stringify(frame));
+ }
+});
+
+test('a saved action reported mid-stream reaches the handler before the answer text', async () => {
+ const { log, names, handlers } = recordHandlers();
+ const result = await readStream(
+ async () => sseResponse([creationFrame(), { content: 'Hello ' }, { content: 'world' }, { done: true, message_id: 'a-1' }]),
+ handlers,
+ );
+ assert.deepEqual(names(), ['actions', 'content', 'content', 'done']);
+ assert.equal(log[0][1].pending_action.id, 'act-1');
+ assert.equal(result.accumulated, 'Hello world');
+ assert.equal(result.completed, true);
+ assert.equal(result.errored, false);
+});
+
+test('a reply that carries its actions on the finishing frame reports them before it finishes', async () => {
+ const { log, names, handlers } = recordHandlers();
+ await readStream(
+ async () =>
+ sseResponse([
+ { content: 'Saved.' },
+ { done: true, message_id: 'a-1', m365_pending_actions: [action('act-1', { status: 'completed' })] },
+ ]),
+ handlers,
+ );
+ assert.deepEqual(names(), ['content', 'actions', 'done']);
+ assert.equal(log[1][1].m365_pending_actions[0].status, 'completed');
+});
+
+test('a reply that fails after saving an action reports the action before the error', async () => {
+ const { log, names, handlers } = recordHandlers();
+ const result = await readStream(
+ async () => sseResponse([{ error: 'Model failed', m365_pending_actions: [action('act-2')] }]),
+ handlers,
+ );
+ assert.deepEqual(names(), ['actions', 'error']);
+ assert.equal(log[0][1].m365_pending_actions[0].id, 'act-2');
+ assert.equal(log[1][1], 'Model failed');
+ assert.equal(result.errored, true);
+});
+
+test('a refused request that could not list its actions still says so before the error', async () => {
+ const { log, names, handlers } = recordHandlers();
+ const result = await readStream(
+ async () =>
+ json(
+ { error: 'Chat is unavailable', m365_pending_actions_error: { message: 'Outgoing actions could not be verified.' } },
+ 500,
+ ),
+ handlers,
+ );
+ assert.deepEqual(names(), ['actions', 'error']);
+ assert.equal(log[0][1].m365_pending_actions_error.message, 'Outgoing actions could not be verified.');
+ assert.equal(log[1][1], 'Chat is unavailable');
+ assert.equal(result.errored, true);
+});
+
+test('a refused request with nothing to say about actions reports only the error', async () => {
+ const { names, handlers } = recordHandlers();
+ await readStream(async () => json({ error: 'Chat is unavailable' }, 500), handlers);
+ assert.deepEqual(names(), ['error']);
+});
+
+test('a stream that saves nothing never reaches the pending-action handler', async () => {
+ const { names, handlers } = recordHandlers();
+ await readStream(async () => sseResponse([{ content: 'Hi' }, { done: true, message_id: 'a-1' }]), handlers);
+ assert.deepEqual(names(), ['content', 'done']);
+});
+
+/* A real send: where the card is drawn while the reply streams ------------------------- */
+
+test('a saved action follows its reply from the streaming turn to the finished message', async () => {
+ await withLiveReply('conv-1', async ({ streams, body, userMessageId }) => {
+ assert.equal(body.conversation_id, 'conv-1');
+ assert.match(userMessageId, /^pending-user-/);
+ assert.equal(store().liveStream.conversationId, 'conv-1');
+ assert.ok(chat().messages.some((message) => message.id === userMessageId));
+
+ streams.emit(STREAM_PATH, creationFrame('act-1'));
+ await settled(() => store().entries['act-1'], 'the saved action to be reported');
+ let reference = store().entries['act-1'].reference;
+ assert.equal(reference.userMessageId, userMessageId);
+ assert.equal(reference.requestId, 'req-1');
+ assert.equal(anchorOf('act-1'), STREAMING_ANCHOR, 'drawn under the streaming reply while it is generated');
+
+ streams.emit(STREAM_PATH, { type: 'user_message_persisted', user_message_id: 'u-real-1' });
+ await settled(() => store().liveStream?.userMessageId === 'u-real-1', 'the persisted turn to be adopted');
+ reference = store().entries['act-1'].reference;
+ assert.equal(reference.userMessageId, 'u-real-1');
+ assert.equal(reference.fallbackMessageId, 'u-real-1');
+ assert.ok(chat().messages.some((message) => message.id === 'u-real-1'));
+ assert.equal(anchorOf('act-1'), STREAMING_ANCHOR, 'the card stays where it was when the turn gets its real id');
+
+ streams.emit(STREAM_PATH, { content: 'I saved the email. ' });
+ streams.emit(STREAM_PATH, {
+ done: true,
+ message_id: 'a-real-1',
+ chat_type: 'group',
+ full_content: 'I saved the email. Review it below.',
+ m365_pending_actions: [action('act-1', { version: 'v2', updated_at: T2 })],
+ });
+ streams.end(STREAM_PATH);
+ await settled(replyFinished, 'the reply to finish');
+
+ const finished = store().entries['act-1'];
+ assert.equal(finished.reference.messageId, 'a-real-1');
+ assert.equal(finished.reference.requestId, 'req-1', 'the request that saved the action is not forgotten');
+ assert.equal(finished.action.version, 'v2');
+ assert.ok(chat().messages.some((message) => message.id === 'a-real-1'));
+ assert.equal(anchorOf('act-1'), 'a-real-1', 'drawn under the finished reply');
+ assert.equal(Object.keys(store().entries).length, 1, 'one card, not one per frame');
+ });
+});
+
+test('a frame that names the hidden source conversation still lands in the conversation on screen', async () => {
+ await withLiveReply('conv-1', async ({ streams, userMessageId }) => {
+ streams.emit(STREAM_PATH, creationFrame('act-1', { conversation_id: 'hidden-source-1' }));
+ await settled(() => store().entries['act-1'], 'the saved action to be reported');
+ assert.equal(store().conversationId, 'conv-1');
+ assert.equal(store().entries['act-1'].reference.userMessageId, userMessageId);
+ assert.equal(anchorOf('act-1'), STREAMING_ANCHOR);
+ streams.emit(STREAM_PATH, { done: true, message_id: 'a-real-1', chat_type: 'group' });
+ streams.end(STREAM_PATH);
+ await settled(replyFinished, 'the reply to finish');
+ });
+});
+
+test('a frame that arrives after the reader opened another conversation is dropped', async () => {
+ await withLiveReply('conv-1', async ({ streams }) => {
+ useChatStore.setState({ activeConversationId: 'conv-2' });
+ assert.equal(store().conversationId, 'conv-2');
+ streams.emit(STREAM_PATH, creationFrame('act-1'));
+ streams.emit(STREAM_PATH, { done: true, message_id: 'a-real-1', chat_type: 'group' });
+ streams.end(STREAM_PATH);
+ await settled(() => chat().streaming === false, 'the stream to be read to its end');
+ assert.deepEqual(Object.keys(store().entries), [], 'the other conversation shows nothing it did not save');
+ });
+});
+
+test('a reply that fails after saving an action still shows the saved action under the turn', async () => {
+ await withLiveReply(
+ 'conv-1',
+ async ({ streams, userMessageId }) => {
+ streams.emit(STREAM_PATH, { error: 'Model failed', request_id: 'req-2', m365_pending_actions: [action('act-2')] });
+ streams.end(STREAM_PATH);
+ await settled(() => chat().streamError && replyFinished(), 'the failed reply to settle');
+
+ assert.equal(chat().streamError, 'Model failed');
+ const entry = store().entries['act-2'];
+ assert.ok(entry, 'the action the failed reply saved is still shown');
+ assert.equal(entry.reference.requestId, 'req-2');
+ assert.equal(anchorOf('act-2'), userMessageId, 'with no reply to hold it, the card stays under the turn');
+ },
+ { answers: { '/api/chat/stream/status/conv-1': { pending: false } } },
+ );
+});
+
+test('a card with no reply and no turn on screen is drawn in the conversation section', async () => {
+ openConversation('conv-1');
+ store().handleStreamPayload(creationFrame('act-1'), {
+ conversationId: 'conv-1',
+ messageId: '',
+ userMessageId: 'u-gone',
+ requestId: '',
+ });
+ assert.equal(anchorOf('act-1'), ORPHAN_ANCHOR);
+ resetStores();
+});
+
+/* After a reply ends: the conversation's saved actions are read again ------------------ */
+
+/** What the list route answers for a conversation holding `items`. */
+const listOf = (...items) => ({ success: true, pending_actions: items, continuation_token: '' });
+
+test('a finished reply reads the conversation\'s saved actions again, once, after it ends', async () => {
+ await withLiveReply(
+ 'conv-1',
+ async ({ streams }) => {
+ streams.emit(STREAM_PATH, { content: 'I saved the email. ' });
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ assert.equal(streams.listRequests().length, 0, 'nothing is read while the reply is still being written');
+
+ streams.emit(STREAM_PATH, {
+ done: true,
+ message_id: 'a-real-1',
+ chat_type: 'group',
+ full_content: 'I saved the email. Review it below.',
+ });
+ streams.end(STREAM_PATH);
+ await settled(replyFinished, 'the reply to finish');
+ await settled(() => store().entries['act-late'], 'the saved action to be listed');
+
+ const reads = streams.listRequests();
+ assert.equal(reads.length, 1, 'one read, not one per frame');
+ assert.equal(reads[0].method, 'GET');
+ assert.equal(reads[0].query.conversation_id, 'conv-1');
+ assert.equal(reads[0].query.limit, '30');
+ assert.equal(anchorOf('act-late'), ORPHAN_ANCHOR, 'an action the stream never described is drawn in the conversation section');
+ },
+ { answers: { [LIST_PATH]: listOf(action('act-late')) } },
+ );
+});
+
+test('a reply that fails reads the saved actions again, because it may have saved one the stream never described', async () => {
+ await withLiveReply(
+ 'conv-1',
+ async ({ streams }) => {
+ streams.emit(STREAM_PATH, { error: 'Model failed', request_id: 'req-3' });
+ streams.end(STREAM_PATH);
+ await settled(() => chat().streamError && replyFinished(), 'the failed reply to settle');
+ await settled(() => store().entries['act-late'], 'the saved action to be listed');
+
+ assert.equal(chat().streamError, 'Model failed');
+ assert.equal(streams.listRequests().length, 1);
+ },
+ { answers: { '/api/chat/stream/status/conv-1': { pending: false }, [LIST_PATH]: listOf(action('act-late')) } },
+ );
+});
+
+test('a reply the reader stops reads the saved actions again once the server has the stop request', async () => {
+ await withLiveReply(
+ 'conv-1',
+ async ({ streams }) => {
+ assert.equal(streams.listRequests().length, 0);
+ chat().stopStreaming();
+ await settled(() => store().entries['act-late'], 'the saved action to be listed');
+
+ const stops = streams.requests.filter((request) => request.path === '/api/chat/stream/cancel/conv-1');
+ const reads = streams.listRequests();
+ assert.equal(stops.length, 1);
+ assert.equal(reads.length, 1);
+ assert.ok(
+ streams.requests.indexOf(stops[0]) < streams.requests.indexOf(reads[0]),
+ 'the list is read after the stop was sent, so it cannot miss what the stop was too late to prevent',
+ );
+ },
+ { answers: { '/api/chat/stream/cancel/conv-1': { success: true }, [LIST_PATH]: listOf(action('act-late')) } },
+ );
+});
+
+test('a reply that ends after the reader opened another conversation does not read the list of the one on screen', async () => {
+ await withLiveReply(
+ 'conv-1',
+ async ({ streams }) => {
+ useChatStore.setState({ activeConversationId: 'conv-2' });
+ streams.emit(STREAM_PATH, { done: true, message_id: 'a-real-1', chat_type: 'group' });
+ streams.end(STREAM_PATH);
+ await settled(() => chat().streaming === false, 'the stream to be read to its end');
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ assert.deepEqual(streams.listRequests(), []);
+ assert.deepEqual(Object.keys(store().entries), []);
+ },
+ { answers: { [LIST_PATH]: listOf(action('act-late')) } },
+ );
+});
+
+/** A server for a conversation whose reply is still being written when the reader opens it. */
+function serveRunningReply(conversationId) {
+ const encoder = new TextEncoder();
+ const requests = [];
+ const original = globalThis.fetch;
+ let reattached = null;
+ globalThis.fetch = async (input, init = {}) => {
+ const url = requestUrl(input);
+ requests.push({ method: init.method ?? 'GET', path: url.pathname, query: Object.fromEntries(url.searchParams) });
+ if (url.pathname === '/api/get_messages') return json({ messages: [] });
+ if (url.pathname === `/api/chat/stream/status/${conversationId}`) return json({ pending: true });
+ if (url.pathname === `/api/chat/stream/reattach/${conversationId}`) {
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ reattached = controller;
+ },
+ }),
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
+ );
+ }
+ if (url.pathname === LIST_PATH) return json(listOf(action('act-late')));
+ return json({ error: 'Not found' }, 404);
+ };
+ return {
+ listRequests: () => requests.filter((request) => request.path === LIST_PATH),
+ attached: () => reattached !== null,
+ emit(event) {
+ reattached.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
+ },
+ end() {
+ try {
+ reattached?.close();
+ } catch {
+ // The reader already stopped at the terminal frame.
+ }
+ },
+ restore() {
+ globalThis.fetch = original;
+ },
+ };
+}
+
+test('a reply picked up again after the reader was away reads the saved actions when it ends', async () => {
+ resetStores();
+ const server = serveRunningReply('conv-away');
+ try {
+ await chat().selectConversation('conv-away', { kind: 'personal' });
+ await settled(() => server.attached() && chat().streaming === true, 'the running reply to be picked up');
+ assert.equal(store().conversationId, 'conv-away');
+ assert.equal(server.listRequests().length, 0, 'nothing is read while the reply is still being written');
+
+ server.emit({ content: 'Still writing. ' });
+ server.emit({ done: true, message_id: 'a-away-1', chat_type: 'group', full_content: 'Still writing. Done.' });
+ server.end();
+ await settled(() => chat().streaming === false, 'the picked-up reply to end');
+ await settled(() => store().entries['act-late'], 'the saved action to be listed');
+ assert.equal(server.listRequests().length, 1);
+ assert.equal(server.listRequests()[0].query.conversation_id, 'conv-away');
+ } finally {
+ server.end();
+ server.restore();
+ resetStores();
+ }
+});
+
+/* Stopping a reply: its saved actions stay under the turn that asked for them ---------- */
+
+test('a reply the reader stops leaves its saved action under the turn that asked for it', async () => {
+ await withLiveReply(
+ 'conv-1',
+ async ({ streams, userMessageId }) => {
+ streams.emit(STREAM_PATH, creationFrame('act-1'));
+ await settled(() => store().entries['act-1'], 'the saved action to be reported');
+ assert.equal(anchorOf('act-1'), STREAMING_ANCHOR, 'drawn under the streaming reply while it is generated');
+
+ chat().stopStreaming();
+ assert.equal(store().liveStream, null, 'a reply nobody is reading is not a streaming reply');
+ assert.equal(anchorOf('act-1'), userMessageId, 'the card leaves the streaming slot, which nothing will fill again');
+ await settled(() => streams.listRequests().length === 1, 'the list to be read after the stop');
+ assert.equal(anchorOf('act-1'), userMessageId, 'reading the list again does not move it');
+ },
+ { answers: { '/api/chat/stream/cancel/conv-1': { success: true }, [LIST_PATH]: listOf() } },
+ );
+});
+
+test('a message sent after a stopped reply does not take the stopped reply\'s saved action with it', async () => {
+ await withLiveReply(
+ 'conv-1',
+ async ({ streams, userMessageId }) => {
+ streams.emit(STREAM_PATH, creationFrame('act-1'));
+ await settled(() => store().entries['act-1'], 'the saved action to be reported');
+ chat().stopStreaming();
+ await settled(() => streams.listRequests().length === 1, 'the list to be read after the stop');
+
+ const opened = streams.opened(STREAM_PATH);
+ void chat().sendMessage('And one more', {});
+ await opened;
+ const nextUserMessageId = store().liveStream?.userMessageId ?? '';
+ assert.match(nextUserMessageId, /^pending-user-/);
+ assert.notEqual(nextUserMessageId, userMessageId, 'the new message is a new turn');
+
+ assert.equal(store().entries['act-1'].reference.userMessageId, userMessageId, 'the card still belongs to the turn that saved it');
+ assert.equal(anchorOf('act-1'), userMessageId, 'and is drawn under it, not under the new reply');
+ },
+ { answers: { '/api/chat/stream/cancel/conv-1': { success: true }, [LIST_PATH]: listOf() } },
+ );
+});
+
+/* Shared conversations: every participant is told to read the list again --------------- */
+
+function pendingActionEvent(conversationId, payload = {}, occurredAt = new Date().toISOString()) {
+ return {
+ conversation_id: conversationId,
+ event_type: 'collaboration.m365.pending_action',
+ occurred_at: occurredAt,
+ payload: { m365_pending_action_ids: ['act-7'], request_id: 'req-7', ...payload },
+ };
+}
+
+/** Every handler the event could reach, recorded by name, so nothing but the intended one can slip by. */
+function recordingHandlers() {
+ const calls = [];
+ const handlers = new Proxy(
+ {},
+ {
+ get:
+ (_target, name) =>
+ (...args) => {
+ calls.push([String(name), ...args]);
+ },
+ },
+ );
+ return { calls, handlers };
+}
+
+function installFakeEventSource() {
+ const sources = [];
+ class FakeEventSource {
+ constructor(url, init) {
+ this.url = url;
+ this.init = init;
+ this.closed = false;
+ this.onmessage = null;
+ this.onerror = null;
+ sources.push(this);
+ }
+
+ close() {
+ this.closed = true;
+ }
+
+ deliver(data) {
+ this.onmessage?.({ data: typeof data === 'string' ? data : JSON.stringify(data) });
+ }
+ }
+ globalThis.EventSource = FakeEventSource;
+ return {
+ sources,
+ restore() {
+ delete globalThis.EventSource;
+ },
+ };
+}
+
+test('a pending-action event gives the actions it names, the request that saved them and whether they could be listed', () => {
+ const { calls, handlers } = recordingHandlers();
+
+ dispatchCollaborationEvent(
+ pendingActionEvent('conv-shared', { m365_pending_action_ids: ['act-1', '', 7, null, 'act-2'], request_id: 'req-1' }),
+ handlers,
+ );
+ dispatchCollaborationEvent(
+ pendingActionEvent('conv-shared', { m365_pending_action_ids: 'act-1', request_id: 42 }),
+ handlers,
+ );
+ dispatchCollaborationEvent(
+ pendingActionEvent('conv-shared', { m365_pending_action_ids: [], error: 'm365_pending_actions_unavailable' }),
+ handlers,
+ );
+ dispatchCollaborationEvent(
+ pendingActionEvent('conv-shared', { m365_pending_action_ids: [], error: 'something_else' }),
+ handlers,
+ );
+
+ assert.deepEqual(calls, [
+ ['onM365PendingActions', { actionIds: ['act-1', 'act-2'], requestId: 'req-1', unavailable: false }],
+ ['onM365PendingActions', { actionIds: [], requestId: '', unavailable: false }],
+ ['onM365PendingActions', { actionIds: [], requestId: 'req-7', unavailable: true }],
+ ['onM365PendingActions', { actionIds: [], requestId: 'req-7', unavailable: false }],
+ ]);
+});
+
+test('the event subscription opens the conversation stream and closes it when detached', () => {
+ const events = installFakeEventSource();
+ try {
+ const { calls, handlers } = recordingHandlers();
+ const detach = subscribeToCollaborationEvents('conv-shared', handlers);
+ assert.equal(events.sources.length, 1);
+ assert.equal(events.sources[0].url, '/api/collaboration/conversations/conv-shared/events');
+ const when = new Date().toISOString();
+
+ events.sources[0].deliver(pendingActionEvent('conv-shared', {}, when));
+ events.sources[0].deliver(pendingActionEvent('conv-shared', {}, when));
+ events.sources[0].deliver(pendingActionEvent('conv-shared', { m365_pending_action_ids: ['act-8'] }, when));
+ events.sources[0].deliver(pendingActionEvent('conv-shared', { request_id: 'req-8' }, when));
+ assert.deepEqual(
+ calls.map(([name, change]) => [name, change.actionIds, change.requestId]),
+ [
+ ['onM365PendingActions', ['act-7'], 'req-7'],
+ ['onM365PendingActions', ['act-8'], 'req-7'],
+ ['onM365PendingActions', ['act-7'], 'req-8'],
+ ],
+ 'an exact repeat is dropped, an event for other actions or another request is not',
+ );
+
+ detach();
+ assert.equal(events.sources[0].closed, true);
+ } finally {
+ events.restore();
+ }
+});
+
+test('history replayed on connect and frames that cannot be read do not touch the list', () => {
+ const events = installFakeEventSource();
+ try {
+ const { calls, handlers } = recordingHandlers();
+ subscribeToCollaborationEvents('conv-shared', handlers);
+ const [source] = events.sources;
+
+ source.deliver(pendingActionEvent('conv-shared', {}, '2020-01-01T00:00:00Z'));
+ source.deliver('');
+ source.deliver('{not json');
+ assert.deepEqual(calls, []);
+
+ source.deliver(pendingActionEvent('conv-shared'));
+ assert.equal(calls.length, 1, 'a live event still gets through after them');
+ } finally {
+ events.restore();
+ }
+});
+
+test('without an EventSource, or a conversation, there is nothing to subscribe to', () => {
+ assert.equal(typeof globalThis.EventSource, 'undefined');
+ const { handlers } = recordingHandlers();
+ const detach = subscribeToCollaborationEvents('conv-shared', handlers);
+ assert.equal(typeof detach, 'function');
+ detach();
+
+ const events = installFakeEventSource();
+ try {
+ subscribeToCollaborationEvents('', handlers)();
+ assert.equal(events.sources.length, 0);
+ } finally {
+ events.restore();
+ }
+});
+
+/** A server for a shared conversation: its thread is empty and its saved actions are `listing()`. */
+function serveSharedConversation(listing = () => json({ pending_actions: [action('act-7')], continuation_token: '' })) {
+ const requests = [];
+ const original = globalThis.fetch;
+ globalThis.fetch = async (input, init = {}) => {
+ const url = requestUrl(input);
+ requests.push({ method: init.method ?? 'GET', path: url.pathname, query: Object.fromEntries(url.searchParams) });
+ if (url.pathname === '/api/collaboration/conversations/conv-shared/messages') return json({ messages: [] });
+ if (url.pathname === LIST_PATH) return listing();
+ return json({ error: 'Not found' }, 404);
+ };
+ return {
+ requests,
+ listRequests: () => requests.filter((request) => request.path === LIST_PATH),
+ restore() {
+ globalThis.fetch = original;
+ },
+ };
+}
+
+async function withSharedConversation(run, listing) {
+ resetStores();
+ const events = installFakeEventSource();
+ const server = serveSharedConversation(listing);
+ try {
+ await chat().selectConversation('conv-shared', { kind: 'collaborative' });
+ assert.equal(chat().activeConversationId, 'conv-shared');
+ assert.equal(store().conversationId, 'conv-shared');
+ assert.equal(events.sources.length, 1, 'the open conversation is subscribed to');
+ await run({ source: events.sources[0], server });
+ } finally {
+ server.restore();
+ events.restore();
+ resetStores();
+ }
+}
+
+test('a shared conversation reads its saved actions again when the server announces one', async () => {
+ await withSharedConversation(async ({ source, server }) => {
+ assert.equal(server.listRequests().length, 0, 'nothing is listed until something is announced');
+
+ source.deliver(pendingActionEvent('conv-shared'));
+ await settled(() => store().entries['act-7'], 'the announced action to be listed');
+
+ const [listing] = server.listRequests();
+ assert.equal(listing.method, 'GET');
+ assert.equal(listing.query.conversation_id, 'conv-shared');
+ assert.equal(listing.query.limit, '30');
+ assert.equal(anchorOf('act-7'), ORPHAN_ANCHOR, 'a card with no place in the thread is drawn in the conversation section');
+ });
+});
+
+test('a shared conversation says its saved actions could not be listed until the list is read again', async () => {
+ const listing = deferred();
+ await withSharedConversation(
+ async ({ source, server }) => {
+ source.deliver(pendingActionEvent('conv-shared', { m365_pending_action_ids: [], error: 'm365_pending_actions_unavailable' }));
+ await settled(() => store().referenceError === PENDING_ACTIONS_UNAVAILABLE_MESSAGE, 'the unavailable notice');
+ await settled(() => server.listRequests().length === 1, 'the list to be read again');
+
+ listing.resolve();
+ await settled(() => store().listStatus === 'loaded', 'the list to be read');
+ assert.equal(store().referenceError, '', 'the notice is withdrawn once the list is verified');
+ assert.ok(store().entries['act-7']);
+ },
+ () => listing.promise.then(() => json({ pending_actions: [action('act-7')], continuation_token: '' })),
+ );
+});
+
+test('an event for a conversation the reader has left does not read any list', async () => {
+ await withSharedConversation(async ({ source, server }) => {
+ useChatStore.setState({ activeConversationId: 'conv-other' });
+ source.deliver(pendingActionEvent('conv-shared'));
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ assert.equal(server.listRequests().length, 0);
+ assert.deepEqual(Object.keys(store().entries), []);
+ });
+});
diff --git a/functional_tests/test_v2_notifications_bell.py b/functional_tests/test_v2_notifications_bell.py
index f3d8f12cf..2fb1a900c 100644
--- a/functional_tests/test_v2_notifications_bell.py
+++ b/functional_tests/test_v2_notifications_bell.py
@@ -128,7 +128,13 @@
(
"/chats?conversationId=conv-5&m365_pending_action=act-1",
{},
- {"kind": "classic", "href": "/chats?conversationId=conv-5&m365_pending_action=act-1", "groupId": None},
+ {"kind": "conversation", "conversationId": "conv-5", "pendingActionId": "act-1"},
+ ),
+ # An action id a link must not carry opens the conversation without the focus request.
+ (
+ "/chats?conversationId=conv-5&m365_pending_action=act%2F1",
+ {},
+ {"kind": "conversation", "conversationId": "conv-5"},
),
(
"/approvals?approval_id=a-1",
@@ -378,7 +384,9 @@ def test_the_seams_later_tracks_build_on_are_in_place():
assert "export function v2WorkflowRunPath(" in links
assert links.count("v2WorkflowRunPath(") >= 3
# Links are read with the chat page's own reader, so both accept the same spellings.
- assert "import { readConversationParam } from './conversationUrl';" in links
+ assert "import { readConversationParam, readPendingActionFocus } from './conversationUrl';" in links
+ # The saved action a chat link names is read with the chat page's own reader too.
+ assert "readPendingActionFocus(url.searchParams)" in links
# Workflow runs announce delivered results through the same channel as chat replies.
events = read_v2("lib/replyEvents.ts")
diff --git a/functional_tests/test_v2_user_settings_memory_m365.py b/functional_tests/test_v2_user_settings_memory_m365.py
index 378310dc3..5d2c3e806 100644
--- a/functional_tests/test_v2_user_settings_memory_m365.py
+++ b/functional_tests/test_v2_user_settings_memory_m365.py
@@ -210,7 +210,11 @@ def test_m365_links_stay_in_v2():
V2_SRC / "components" / "chat" / "WorkflowProposalCard.tsx",
V2_SRC / "components" / "chat" / "WorkflowRunCard.tsx",
V2_SRC / "components" / "approvals" / "PendingActionsPanel.tsx",
+ V2_SRC / "components" / "approvals" / "PendingActionCard.tsx",
V2_SRC / "components" / "approvals" / "PausedRequestsPanel.tsx",
+ V2_SRC / "components" / "chat" / "PendingActionSlots.tsx",
+ V2_SRC / "stores" / "m365PendingActionsStore.ts",
+ V2_SRC / "lib" / "m365PendingActions.ts",
):
source = _read(path)
assert not re.search(r"['\"`]/profile", source), f"{path.name} links to the classic Profile page"
diff --git a/functional_tests/test_v2_workflow_run_link_routing.mjs b/functional_tests/test_v2_workflow_run_link_routing.mjs
index 7c73808cd..d491dab17 100644
--- a/functional_tests/test_v2_workflow_run_link_routing.mjs
+++ b/functional_tests/test_v2_workflow_run_link_routing.mjs
@@ -258,7 +258,7 @@ test('a workflow-activity link the notice does not agree with keeps the classic
}
});
-test('a Microsoft 365 notice keeps the classic page wherever its action id was written', () => {
+test('a Microsoft 365 notice about a workflow run keeps the classic page wherever its action id was written', () => {
const link = activityLink('wf-1', 'run-1');
const metadata = { workflow_id: 'wf-1', run_id: 'run-1', workflow_scope: 'personal' };
for (const value of ['act-1', '', null]) {
@@ -278,12 +278,20 @@ test('a Microsoft 365 notice keeps the classic page wherever its action id was w
resolve({ link_url: link, metadata, link_context: { m365_pending_action_id: 'act-1', group_id: 'grp-3' } }),
classicAt(link, 'grp-3'),
);
- // A chat link to a pending action opens classic's chat page, the only one that renders it.
+});
+
+test('a chat link to a Microsoft 365 action opens the V2 conversation and names the action', () => {
+ // V2 chat renders the saved action's card, so this no longer goes through classic.
assert.deepEqual(resolve({
notification_type: 'm365_approval_requested',
link_url: '/chats?conversationId=conv-1&m365_pending_action=act-1',
metadata: { m365_pending_action_id: 'act-1' },
- }), classicAt('/chats?conversationId=conv-1&m365_pending_action=act-1'));
+ }), { target: { kind: 'conversation', conversationId: 'conv-1', pendingActionId: 'act-1' }, error: null });
+ // An action id a link must not carry opens the conversation without it.
+ assert.deepEqual(resolve({
+ link_url: '/chats?conversationId=conv-1&m365_pending_action=act%2F1',
+ metadata: { m365_pending_action_id: 'act-1' },
+ }), { target: { kind: 'conversation', conversationId: 'conv-1' }, error: null });
});
test('a workflow notice without a link opens the run its metadata names, and nothing else does', () => {
diff --git a/scripts/check_xss_sinks.py b/scripts/check_xss_sinks.py
index 35b20d97a..34c89a6d5 100644
--- a/scripts/check_xss_sinks.py
+++ b/scripts/check_xss_sinks.py
@@ -593,6 +593,9 @@ def collect_marked_parse_issues(
'artifactDownloadPath',
'chatHrefForAgent',
'chatHrefForConversation',
+ # chatHrefForConversation plus a literal '&m365_pending_action=' and an
+ # encodeURIComponent saved-action id that normalizePendingActionId has accepted.
+ 'chatHrefForPendingAction',
'chatHrefForPrompt',
'chatUploadTabularDownloadUrl',
'classicChatHref',
diff --git a/ui_tests/fixtures/orchestration/harness_entry.tsx b/ui_tests/fixtures/orchestration/harness_entry.tsx
index 516710844..0c90c91ec 100644
--- a/ui_tests/fixtures/orchestration/harness_entry.tsx
+++ b/ui_tests/fixtures/orchestration/harness_entry.tsx
@@ -21,6 +21,7 @@ import * as generatedDocumentsStore from '../../../application/v2_ui/src/stores/
import * as generatedExportRunStore from '../../../application/v2_ui/src/stores/generatedExportRunStore';
import * as userSettingsStore from '../../../application/v2_ui/src/stores/userSettingsStore';
import * as assistThreadStore from '../../../application/v2_ui/src/stores/assistThreadStore';
+import * as m365PendingActionsStore from '../../../application/v2_ui/src/stores/m365PendingActionsStore';
import * as controller from '../../../application/v2_ui/src/lib/orchestrationController';
import * as plan from '../../../application/v2_ui/src/lib/orchestrationPlan';
import * as orchestration from '../../../application/v2_ui/src/lib/orchestration';
@@ -302,6 +303,9 @@ function reset(): void {
// Assist threads live in memory only, so a test reusing an editor would otherwise see
// the previous test's exchanges and draft.
assistThreadStore.useAssistThreadStore.getState().resetThreads();
+ // Saved Microsoft 365 actions belong to the conversation they were read for, so a test
+ // reusing a conversation id would otherwise start from the previous test's cards.
+ m365PendingActionsStore.chatPendingActionsStore.getState().reset();
try {
window.localStorage.clear();
} catch {
@@ -326,6 +330,7 @@ const harness = {
generatedExportRuns: generatedExportRunStore,
userSettings: userSettingsStore,
assistThread: assistThreadStore,
+ pendingActions: m365PendingActionsStore,
},
controller,
plan,
diff --git a/ui_tests/fixtures/v2_pending_action_stubs.py b/ui_tests/fixtures/v2_pending_action_stubs.py
new file mode 100644
index 000000000..06e837272
--- /dev/null
+++ b/ui_tests/fixtures/v2_pending_action_stubs.py
@@ -0,0 +1,30 @@
+# v2_pending_action_stubs.py
+"""
+The saved-action list request the V2 chat makes whenever a conversation is open.
+Version: 0.261.307
+Implemented in: 0.261.307
+
+The chat reads a conversation's saved Microsoft 365 actions (an email or a calendar change
+waiting behind Send, Send now and Cancel) when the conversation opens, when the window comes back
+into view, and after each reply ends. Closed-API fixtures that serve the built SPA answer it with
+these helpers, as a user with nothing saved would see it, so a suite about something else is not
+failed by a request it was never written to expect.
+
+A suite about the cards themselves builds the list with `pending_actions_payload(actions)`.
+"""
+
+PENDING_ACTIONS_PATH = "/api/msgraph/pending-actions"
+
+
+def pending_actions_payload(actions=None, continuation_token=None):
+ """The answer of the pending-actions list route: one page of saved actions."""
+ return {
+ "success": True,
+ "pending_actions": list(actions or []),
+ "continuation_token": continuation_token,
+ }
+
+
+def is_pending_actions_list(method, path):
+ """The conversation's list request. One action is read at `PATH/`, which this skips."""
+ return method == "GET" and path == PENDING_ACTIONS_PATH
diff --git a/ui_tests/fixtures/workspace_authoring.py b/ui_tests/fixtures/workspace_authoring.py
index ca754a827..3e01a98a6 100644
--- a/ui_tests/fixtures/workspace_authoring.py
+++ b/ui_tests/fixtures/workspace_authoring.py
@@ -38,6 +38,7 @@
conversation_mark_read_payload, is_notification_count, mark_read_conversation,
notification_count_payload,
)
+from ui_tests.fixtures.v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload
ROOT = Path(__file__).resolve().parents[2]
@@ -775,6 +776,9 @@ def _dispatch(self, route, entry):
elif mark_read_conversation(method, path):
# A personal reply the reader watched finish is marked read.
self._json(route, conversation_mark_read_payload(mark_read_conversation(method, path)))
+ elif is_pending_actions_list(method, path):
+ # A conversation's saved outgoing Microsoft 365 actions: none to show.
+ self._json(route, pending_actions_payload())
elif path == "/api/user/agent/settings" and method == "GET":
assert entry.query == {"view": ["editor"]}
self._json(route, self.options)
diff --git a/ui_tests/test_foundry_consent_v2.py b/ui_tests/test_foundry_consent_v2.py
index 64efd39f0..fcf1a0066 100644
--- a/ui_tests/test_foundry_consent_v2.py
+++ b/ui_tests/test_foundry_consent_v2.py
@@ -18,6 +18,7 @@
from ui_tests.fixtures.agent_delegation.harness_build import ensure_bundle
from ui_tests.fixtures.orchestration.harness_build import start_static_server
+from ui_tests.fixtures.v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload
ROOT = Path(__file__).resolve().parents[1]
@@ -79,6 +80,8 @@ def respond(payload, status=200):
respond({})
elif "/api/chat/stream/status/" in path:
respond({"pending": False})
+ elif is_pending_actions_list(route.request.method, path):
+ respond(pending_actions_payload())
elif path == "/api/agents/foundry-auth":
route.fulfill(
content_type="text/html",
diff --git a/ui_tests/test_v2_assist_thread.py b/ui_tests/test_v2_assist_thread.py
index 01b78db44..04fb1f176 100644
--- a/ui_tests/test_v2_assist_thread.py
+++ b/ui_tests/test_v2_assist_thread.py
@@ -41,6 +41,7 @@
import harness_build as hb # noqa: E402
from playwright_connection import connect_options # noqa: E402,F401
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -171,6 +172,9 @@ def handle(self, route):
if path == "/api/v2/orchestration/runs":
route.fulfill(json={"runs": []})
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
if path == "/favicon.ico":
route.fulfill(status=204)
return
diff --git a/ui_tests/test_v2_chat_context_selection.py b/ui_tests/test_v2_chat_context_selection.py
index d3df6f5c1..424eb4fa2 100644
--- a/ui_tests/test_v2_chat_context_selection.py
+++ b/ui_tests/test_v2_chat_context_selection.py
@@ -37,6 +37,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import harness_build as hb # noqa: E402
from playwright_connection import connect_options # noqa: E402,F401
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
from ui_tests.fixtures.public_documents import chat_list # noqa: E402
@@ -123,6 +124,10 @@ def handle(self, route: Route):
# Re-read once a new conversation's first answer lands.
route.fulfill(json={"conversations": [], "has_more": False, "next_cursor": None})
return
+ if is_pending_actions_list(request.method, path):
+ # The open conversation's saved outgoing actions: none here.
+ route.fulfill(json=pending_actions_payload())
+ return
if request.method == "POST" and path in STREAM_PATHS.values():
if path == STREAM_PATHS["chat"]:
diff --git a/ui_tests/test_v2_chat_deleted_messages.py b/ui_tests/test_v2_chat_deleted_messages.py
index 674112f95..f505386ae 100644
--- a/ui_tests/test_v2_chat_deleted_messages.py
+++ b/ui_tests/test_v2_chat_deleted_messages.py
@@ -36,6 +36,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -98,6 +99,9 @@ def handle(self, route):
if path == "/favicon.ico":
route.fulfill(status=204)
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
if path == "/api/get_messages":
self.reads += 1
route.fulfill(json={"messages": self.visible_messages()})
diff --git a/ui_tests/test_v2_chat_image_uploads.py b/ui_tests/test_v2_chat_image_uploads.py
index 2b30de68e..3670f1471 100644
--- a/ui_tests/test_v2_chat_image_uploads.py
+++ b/ui_tests/test_v2_chat_image_uploads.py
@@ -32,6 +32,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -139,6 +140,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
self.requests.append({"path": path, "query": parsed.query, "method": request.method})
if request.method == "POST" and path == "/api/get_file_content":
body = json.loads(request.post_data or "{}")
diff --git a/ui_tests/test_v2_collaboration_ux.py b/ui_tests/test_v2_collaboration_ux.py
index a2e53d827..12c1ccf1f 100644
--- a/ui_tests/test_v2_collaboration_ux.py
+++ b/ui_tests/test_v2_collaboration_ux.py
@@ -34,6 +34,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -91,6 +92,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
if path.startswith("/captures/"):
route.fulfill(status=200, body=PIXEL, content_type="image/png")
return
diff --git a/ui_tests/test_v2_drawer_generated_files.py b/ui_tests/test_v2_drawer_generated_files.py
index f343ec4ec..d6e300ad5 100644
--- a/ui_tests/test_v2_drawer_generated_files.py
+++ b/ui_tests/test_v2_drawer_generated_files.py
@@ -35,6 +35,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -156,6 +157,9 @@ def handle(self, route):
if path == "/favicon.ico":
route.fulfill(status=204)
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
self.requests.append({"method": request.method, "path": path, "query": query})
if request.method == "GET" and path == GENERATED:
if self.list_status != 200:
diff --git a/ui_tests/test_v2_group_participant_invites.py b/ui_tests/test_v2_group_participant_invites.py
index 2f0579f45..d9c8cc758 100644
--- a/ui_tests/test_v2_group_participant_invites.py
+++ b/ui_tests/test_v2_group_participant_invites.py
@@ -34,6 +34,7 @@
import harness_build as hb # noqa: E402
from functions_chat_content_checks import strip_private_chat_checks # noqa: E402
from playwright_connection import connect_options # noqa: E402,F401
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
from test_group_collaboration_source_storage_fix import ( # noqa: E402
COLLABORATION_FILE,
GROUP_ID,
@@ -103,6 +104,9 @@ def handle(self, route):
request = route.request
url = urlsplit(request.url)
path = url.path
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
self.requests.append((request.method, path))
if request.method == "POST" and re.fullmatch(
diff --git a/ui_tests/test_v2_image_reference_generation.py b/ui_tests/test_v2_image_reference_generation.py
index 2ced35a7b..21b5e4a65 100644
--- a/ui_tests/test_v2_image_reference_generation.py
+++ b/ui_tests/test_v2_image_reference_generation.py
@@ -23,6 +23,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -51,6 +52,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
self.requests.append({"method": request.method, "path": path})
if request.method == "POST" and path == "/upload":
route.fulfill(json={
diff --git a/ui_tests/test_v2_inline_maps.py b/ui_tests/test_v2_inline_maps.py
index 4b38554e9..b2e72d773 100644
--- a/ui_tests/test_v2_inline_maps.py
+++ b/ui_tests/test_v2_inline_maps.py
@@ -36,6 +36,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -173,6 +174,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
if path.startswith("/vendor/openlayers-10.6.1/"):
if not self.openlayers_available:
route.fulfill(status=404, body="")
diff --git a/ui_tests/test_v2_m365_pending_action_cards.py b/ui_tests/test_v2_m365_pending_action_cards.py
new file mode 100644
index 000000000..8de541f8c
--- /dev/null
+++ b/ui_tests/test_v2_m365_pending_action_cards.py
@@ -0,0 +1,556 @@
+# test_v2_m365_pending_action_cards.py
+"""
+UI test for Microsoft 365 pending-action cards in the V2 chat.
+Version: 0.261.307
+Implemented in: 0.261.307
+
+This test ensures that a saved outgoing Microsoft 365 email or calendar action is drawn in the V2
+chat the way the classic chat draws it: live under the streaming reply and then under the saved
+reply that created it, inline under that reply when the conversation is reopened, and in a
+conversation-level section when no visible message claims it. It also ensures that Send and Cancel
+act on the exact saved version through the CSRF-protected endpoints and report what the server
+says, that a conflicting send is reported and never retried, that opening a card never sends it,
+that a notification's deep link scrolls to, highlights and focuses its card exactly once, that a
+card only reachable by id is still found, that a link to a card the viewer cannot see says so, and
+that a failed list is shown and can be read again.
+
+It drives the real bundled V2 components over a local static server with a synthetic HTTP
+boundary, so no Azure credentials or network access are needed.
+"""
+
+import sys
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import expect
+
+from ui_tests.fixtures.playwright_connection import connect_options # noqa: F401
+from ui_tests.fixtures.orchestration import harness_build
+
+ROOT = Path(__file__).resolve().parents[1]
+sys.path.insert(0, str(ROOT / "functional_tests"))
+
+from test_support.versioning import assert_app_version_at_least # noqa: E402
+
+
+pytestmark = pytest.mark.ui
+IMPLEMENTED_IN = "0.261.307"
+CONVERSATION_ID = "conversation-m365"
+LIST_PATH = "/api/msgraph/pending-actions"
+USER_TEXT = "Email Ann the quarterly numbers"
+REPLY_TEXT = "I saved a draft email to Ann. Review it below before it goes out."
+PENDING_STATUS = "Pending \u2014 not sent."
+SENT_STATUS = "Accepted for sending \u2014 recipient delivery is not confirmed."
+CANCELLED_STATUS = "Cancelled \u2014 no further delivery is scheduled for this action."
+SEND_NOTICE = "Send request checked. Review the server status and delivery note below."
+CANCEL_NOTICE = (
+ "Cancellation checked. The server status below is authoritative; "
+ "this does not recall an already sent item."
+)
+CONFLICT_NOTICE = (
+ "This action changed or is already being processed. "
+ "Review its current status and details before making another choice."
+)
+UNAVAILABLE = (
+ "That Microsoft 365 action is not available in this conversation. "
+ "It may have been removed, or you may not have access to it."
+)
+OWNER_ONLY = "Only the action owner can send or cancel this action. This view is read-only."
+SECTION_NAME = "Microsoft 365 outgoing actions for this conversation"
+
+# Replaces window.fetch with a backend for the endpoints the cards use. Every request is logged on
+# window.requests, so a test can say exactly what the page asked for and what it posted.
+STUB_BACKEND = r"""(config) => {
+ const encoder = new TextEncoder();
+ const json = (body, status = 200) => new Response(JSON.stringify(body), {
+ status, headers: { 'Content-Type': 'application/json' },
+ });
+ window.requests = [];
+ window.listFails = Boolean(config.listFails);
+ window.changeStatus = 200;
+ window.listedActions = config.list;
+ window.actionsById = Object.fromEntries(config.byId.map((action) => [action.id, action]));
+ window.fetch = async (url, init = {}) => {
+ const target = new URL(String(url), window.location.origin);
+ const path = target.pathname;
+ const method = String(init.method || 'GET').toUpperCase();
+ window.requests.push({
+ method, path, search: target.search, headers: { ...(init.headers || {}) },
+ body: typeof init.body === 'string' ? JSON.parse(init.body) : null,
+ });
+ if (path === '/api/chat/stream') {
+ return new Response(new ReadableStream({
+ start(controller) {
+ window.pushChatFrame = (frame) => controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
+ window.closeChatFrames = () => controller.close();
+ },
+ }), { headers: { 'Content-Type': 'text/event-stream' } });
+ }
+ if (path === '/api/m365/preferences') {
+ return json({ success: true, csrf_token: 'c'.repeat(40) });
+ }
+ if (path === '/api/msgraph/pending-actions' && method === 'GET') {
+ if (window.listFails) {
+ return json({ success: false, message: 'The list is unavailable.' }, 500);
+ }
+ return json({ success: true, pending_actions: window.listedActions, continuation_token: '' });
+ }
+ const detail = path.match(/^\/api\/msgraph\/pending-actions\/([^/]+)$/);
+ if (detail && method === 'GET') {
+ const action = window.actionsById[decodeURIComponent(detail[1])];
+ return action
+ ? json({ success: true, pending_action: action })
+ : json({ success: false, error: 'not_found', message: 'Not found.' }, 404);
+ }
+ const change = path.match(/^\/api\/msgraph\/pending-actions\/([^/]+)\/(send-now|approve|cancel)$/);
+ if (change && method === 'POST') {
+ const id = decodeURIComponent(change[1]);
+ const current = window.actionsById[id];
+ if (!current) {
+ return json({ success: false, error: 'not_found', message: 'Not found.' }, 404);
+ }
+ if (window.changeStatus !== 200) {
+ return json({ success: false, error: 'version_conflict', message: 'The action changed.' }, window.changeStatus);
+ }
+ const next = {
+ ...current, version: 'v2', status: change[2] === 'cancel' ? 'cancelled' : 'sent',
+ can_cancel: false, can_send_now: false, updated_at: '2026-10-07T16:05:00Z',
+ };
+ window.actionsById[id] = next;
+ return json({ success: true, pending_action: next });
+ }
+ return json({ success: true, messages: [] });
+ };
+}"""
+
+# Opens the conversation in a real MessageList. With `send`, the message is sent over the stubbed
+# chat stream, which the test then drives frame by frame.
+SEED_THREAD = r"""({ messages, send }) => {
+ const H = window.OrchHarness;
+ H.reset();
+ H.stores.bootstrap.useBootstrapStore.setState({
+ data: { features: {}, settings: {}, catalogs: { models: [], agents: [], prompts: [] },
+ user: { id: 'owner', display_name: 'Tester' }, scope: {} },
+ });
+ H.stores.chat.useChatStore.setState({
+ activeConversationId: 'conversation-m365', activeConversationKind: 'personal',
+ messages, streaming: false, messagesLoading: false,
+ });
+ H.mount('mount-a', 'MessageList');
+ if (send) {
+ void H.stores.chat.useChatStore.getState().sendMessage(send, {});
+ }
+}"""
+
+REQUEST_FOCUS = r"""({ id, conversationId }) => {
+ window.OrchHarness.stores.pendingActions.chatPendingActionsStore.getState().requestFocus(id, conversationId);
+}"""
+
+# Records every change to a card's highlight, so a test sees the highlight come and go however
+# quickly it does.
+WATCH_HIGHLIGHT = r"""() => {
+ window.highlightLog = [];
+ new MutationObserver((records) => {
+ for (const record of records) {
+ window.highlightLog.push(record.target.getAttribute('data-highlighted'));
+ }
+ }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['data-highlighted'] });
+}"""
+
+# Records the scroll the deep link asks for, without depending on the page being tall enough to move.
+WATCH_SCROLL = r"""() => {
+ window.scrollCalls = [];
+ const original = Element.prototype.scrollTo;
+ Element.prototype.scrollTo = function (...args) {
+ window.scrollCalls.push(args[0]);
+ return original.apply(this, args);
+ };
+}"""
+
+
+def pending_action(action_id, subject="Quarterly numbers", **overrides):
+ """A saved outgoing email in the shape the server sends it."""
+ action = {
+ "type": "msgraph_pending_action",
+ "id": action_id,
+ "version": "v1",
+ "status": "pending",
+ "operation": "send_mail",
+ "graph_resource_type": "mail",
+ "subject": subject,
+ "summary": {
+ "subject": subject,
+ "body_preview": f"Hello Ann, here is {subject.lower()}.",
+ "body_preview_truncated": False,
+ "to_recipients": ["ann@example.com"],
+ },
+ "can_cancel": True,
+ "can_send_now": True,
+ "viewer_is_owner": True,
+ "review_details_required": False,
+ "conversation_id": CONVERSATION_ID,
+ "updated_at": "2026-10-07T16:00:00Z",
+ }
+ action.update(overrides)
+ return action
+
+
+def thread(*cards, request_id="req-hist"):
+ """A saved thread whose assistant reply carries the given cards."""
+ return [
+ {"id": "u-1", "conversation_id": CONVERSATION_ID, "role": "user", "content": USER_TEXT},
+ {
+ "id": "a-1", "conversation_id": CONVERSATION_ID, "role": "assistant", "content": REPLY_TEXT,
+ "metadata": {"m365_request_id": request_id},
+ "m365_pending_actions": list(cards),
+ },
+ ]
+
+
+def _require_bundle():
+ try:
+ harness_build.ensure_bundle()
+ except harness_build.HarnessUnavailable as exc:
+ pytest.skip(str(exc))
+
+
+def _collect_page_errors(page):
+ errors = []
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ return errors
+
+
+def _open_chat(page, origin, *, messages, listed=(), by_id=(), send=None, listing_fails=False):
+ response = page.goto(f"{origin}/{harness_build.HARNESS_HTML_REL}")
+ assert response is not None and response.ok
+ page.wait_for_function("() => Boolean(window.OrchHarness)")
+ page.evaluate(STUB_BACKEND, {"list": list(listed), "byId": list(by_id), "listFails": listing_fails})
+ page.evaluate(SEED_THREAD, {"messages": messages, "send": send})
+ return page.locator("#mount-a")
+
+
+def _requests(page):
+ return page.evaluate("() => window.requests")
+
+
+def _posts(page):
+ return [request for request in _requests(page) if request["method"] == "POST"]
+
+
+def _posts_to_actions(page):
+ return [request for request in _posts(page) if request["path"].startswith(LIST_PATH + "/")]
+
+
+def _wait_for_list(page):
+ page.wait_for_function(
+ "(path) => window.requests.some((request) => request.method === 'GET' && request.path === path)",
+ arg=LIST_PATH,
+ )
+
+
+def test_version_is_at_least_the_implementing_release():
+ assert_app_version_at_least(IMPLEMENTED_IN)
+
+
+def test_card_follows_its_reply_from_streaming_to_saved(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ action = pending_action("act-live", request_id="req-live")
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(page, origin, messages=[], send=USER_TEXT)
+ page.wait_for_function("() => typeof window.pushChatFrame === 'function'")
+
+ # Saved while the reply is still being written: it appears at once, under the turn that
+ # caused it, ready to be used.
+ page.evaluate(
+ """(action) => window.pushChatFrame({
+ type: 'm365_pending_action', request_id: 'req-live', pending_action: action,
+ })""",
+ action,
+ )
+ card = root.get_by_test_id("v2-pending-action-card")
+ expect(card).to_have_count(1)
+ expect(card).to_have_attribute("data-pending-action-id", "act-live")
+ expect(card.get_by_test_id("v2-pending-action-status")).to_have_text(PENDING_STATUS)
+ expect(card.get_by_test_id("v2-pending-action-send")).to_be_enabled()
+ expect(card.get_by_test_id("v2-pending-action-cancel")).to_be_enabled()
+ expect(root.get_by_test_id("v2-pending-action-slot")).to_have_count(1)
+ expect(root.get_by_test_id("v2-pending-actions-section")).to_have_count(0)
+ user_box = root.get_by_text(USER_TEXT, exact=True).first.bounding_box()
+ assert user_box is not None
+ assert card.bounding_box()["y"] >= user_box["y"] + user_box["height"] - 1
+
+ # The server saves the user's message under its own id part-way through: the card must
+ # not lose its place.
+ page.evaluate(
+ "() => window.pushChatFrame({type: 'user_message_persisted', user_message_id: 'u-live'})"
+ )
+ expect(card).to_have_count(1)
+ expect(root.get_by_test_id("v2-pending-action-slot")).to_have_count(1)
+
+ # The reply is saved and the finishing frame lists the action again: still one card, now
+ # under the reply rather than loose at the end of the thread.
+ page.evaluate(
+ """({action, reply}) => {
+ window.pushChatFrame({
+ done: true, role: 'assistant', replace_content: true, content: reply, full_content: reply,
+ message_id: 'reply-1', conversation_id: 'conversation-m365', metadata: {},
+ m365_pending_actions: [action],
+ });
+ window.closeChatFrames();
+ }""",
+ {"action": action, "reply": REPLY_TEXT},
+ )
+ reply = root.get_by_text(REPLY_TEXT, exact=True).first
+ expect(reply).to_be_visible()
+ expect(card).to_have_count(1)
+ expect(root.get_by_test_id("v2-pending-action-slot")).to_have_count(1)
+ expect(root.get_by_test_id("v2-pending-actions-section")).to_have_count(0)
+ reply_box = reply.bounding_box()
+ assert reply_box is not None
+ assert card.bounding_box()["y"] >= reply_box["y"] + reply_box["height"] - 1
+ assert not _posts_to_actions(page)
+ assert errors == []
+
+
+def test_reopened_conversation_draws_cards_under_their_reply_or_in_the_section(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ claimed = pending_action("act-hist", subject="Budget approval", request_id="req-hist")
+ unclaimed = pending_action("act-orphan", subject="Unrelated draft", request_id="req-other")
+ shared = pending_action(
+ "act-other", subject="Shared draft", request_id="req-shared",
+ viewer_is_owner=False, can_cancel=False, can_send_now=False,
+ )
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(
+ page, origin,
+ messages=thread(claimed),
+ listed=[claimed, unclaimed, shared],
+ by_id=[claimed, unclaimed, shared],
+ )
+
+ slot = root.get_by_test_id("v2-pending-action-slot")
+ expect(slot).to_have_count(1)
+ expect(slot).to_have_attribute("aria-label", "Microsoft 365 actions for this message")
+ inline = slot.get_by_test_id("v2-pending-action-card")
+ expect(inline).to_have_count(1)
+ expect(inline).to_have_attribute("data-pending-action-id", "act-hist")
+ expect(inline).to_have_attribute("aria-label", "Microsoft 365 email: Budget approval")
+ expect(inline).to_contain_text("ann@example.com")
+ expect(inline.get_by_role("link", name="Open conversation")).to_have_count(0)
+ # A card opened from history is not usable until the server has confirmed its state.
+ expect(inline.get_by_test_id("v2-pending-action-send")).to_be_enabled()
+ expect(inline.get_by_test_id("v2-pending-action-cancel")).to_be_enabled()
+
+ reply_box = root.get_by_text(REPLY_TEXT, exact=True).first.bounding_box()
+ assert reply_box is not None
+ assert inline.bounding_box()["y"] >= reply_box["y"] + reply_box["height"] - 1
+
+ section = root.get_by_role("region", name=SECTION_NAME)
+ expect(section).to_be_visible()
+ expect(section.get_by_test_id("v2-pending-action-card")).to_have_count(2)
+ expect(section.locator("[data-pending-action-id='act-orphan']")).to_have_count(1)
+ expect(section.locator("[data-pending-action-id='act-other']")).to_have_count(1)
+ expect(section.locator("[data-pending-action-id='act-hist']")).to_have_count(0)
+ expect(section.get_by_test_id("v2-pending-actions-refresh")).to_be_enabled()
+
+ # Someone else's action in a shared conversation can be read but not changed.
+ read_only = section.locator("[data-pending-action-id='act-other']")
+ expect(read_only).to_contain_text(OWNER_ONLY)
+ expect(read_only.get_by_test_id("v2-pending-action-send")).to_have_count(0)
+ expect(read_only.get_by_test_id("v2-pending-action-cancel")).to_have_count(0)
+
+ # Looking at cards never sends or cancels anything.
+ assert not _posts_to_actions(page)
+ requests = _requests(page)
+ listed = [r for r in requests if r["method"] == "GET" and r["path"] == LIST_PATH]
+ assert listed and all(f"conversation_id={CONVERSATION_ID}" in r["search"] for r in listed)
+ confirmed = [r for r in requests if r["path"] == f"{LIST_PATH}/act-hist"]
+ assert confirmed and all(f"conversation_id={CONVERSATION_ID}" in r["search"] for r in confirmed)
+ assert errors == []
+
+
+def test_conversation_without_saved_actions_shows_no_card_or_section(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(page, origin, messages=thread(), listed=[], by_id=[])
+ _wait_for_list(page)
+ expect(root.get_by_text(REPLY_TEXT, exact=True).first).to_be_visible()
+ page.wait_for_timeout(300)
+ expect(root.get_by_test_id("v2-pending-action-card")).to_have_count(0)
+ expect(root.get_by_test_id("v2-pending-action-slot")).to_have_count(0)
+ expect(root.get_by_test_id("v2-pending-actions-section")).to_have_count(0)
+ assert errors == []
+
+
+def test_send_and_cancel_act_on_the_saved_version_and_report_the_servers_answer(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ to_send = pending_action("act-send", subject="Send me")
+ to_cancel = pending_action("act-cancel", subject="Cancel me")
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(
+ page, origin,
+ messages=thread(to_send, to_cancel),
+ listed=[to_send, to_cancel],
+ by_id=[to_send, to_cancel],
+ )
+ send_card = root.locator("[data-pending-action-id='act-send']")
+ cancel_card = root.locator("[data-pending-action-id='act-cancel']")
+ expect(send_card.get_by_test_id("v2-pending-action-send")).to_be_enabled()
+ expect(cancel_card.get_by_test_id("v2-pending-action-cancel")).to_be_enabled()
+ page.evaluate("() => { window.requests.length = 0; }")
+
+ send_card.get_by_test_id("v2-pending-action-send").click()
+ expect(send_card.get_by_test_id("v2-pending-action-status")).to_have_text(SENT_STATUS)
+ expect(send_card.get_by_test_id("v2-pending-action-notice")).to_have_text(SEND_NOTICE)
+ expect(send_card.get_by_test_id("v2-pending-action-send")).to_have_count(0)
+ expect(send_card.get_by_test_id("v2-pending-action-cancel")).to_have_count(0)
+ # The other card is untouched by it.
+ expect(cancel_card.get_by_test_id("v2-pending-action-status")).to_have_text(PENDING_STATUS)
+ expect(cancel_card.get_by_test_id("v2-pending-action-send")).to_be_enabled()
+
+ cancel_card.get_by_test_id("v2-pending-action-cancel").click()
+ expect(cancel_card.get_by_test_id("v2-pending-action-status")).to_have_text(CANCELLED_STATUS)
+ expect(cancel_card.get_by_test_id("v2-pending-action-notice")).to_have_text(CANCEL_NOTICE)
+ expect(cancel_card.get_by_test_id("v2-pending-action-send")).to_have_count(0)
+ expect(cancel_card.get_by_test_id("v2-pending-action-cancel")).to_have_count(0)
+
+ posts = _posts(page)
+ assert [(post["path"], post["body"]) for post in posts] == [
+ (f"{LIST_PATH}/act-send/send-now", {"expected_version": "v1"}),
+ (f"{LIST_PATH}/act-cancel/cancel", {"expected_version": "v1"}),
+ ]
+ for post in posts:
+ assert post["headers"]["X-M365-CSRF-Token"] == "c" * 40
+ assert post["headers"]["X-Requested-With"] == "XMLHttpRequest"
+ assert errors == []
+
+
+def test_a_conflicting_send_is_reported_and_never_retried(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ action = pending_action("act-conflict", subject="Changed elsewhere")
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(page, origin, messages=thread(action), listed=[action], by_id=[action])
+ card = root.locator("[data-pending-action-id='act-conflict']")
+ expect(card.get_by_test_id("v2-pending-action-send")).to_be_enabled()
+ page.evaluate("() => { window.changeStatus = 409; window.requests.length = 0; }")
+
+ card.get_by_test_id("v2-pending-action-send").click()
+ expect(card.get_by_test_id("v2-pending-action-notice")).to_have_text(CONFLICT_NOTICE)
+ # Nothing was sent, so the card still says so and is read again from the server.
+ expect(card.get_by_test_id("v2-pending-action-status")).to_have_text(PENDING_STATUS)
+ expect(card.get_by_test_id("v2-pending-action-send")).to_be_enabled()
+ page.wait_for_timeout(300)
+ assert len(_posts(page)) == 1
+ refreshed = [r for r in _requests(page) if r["method"] == "GET" and r["path"] == f"{LIST_PATH}/act-conflict"]
+ assert refreshed
+ assert errors == []
+
+
+def test_deep_link_scrolls_to_highlights_and_focuses_the_card_once(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ action = pending_action("act-hist", subject="Budget approval")
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(page, origin, messages=thread(action), listed=[action], by_id=[action])
+ page.evaluate(WATCH_HIGHLIGHT)
+ page.evaluate(WATCH_SCROLL)
+ card = root.locator("[data-pending-action-id='act-hist']")
+ expect(card).to_have_count(1)
+ expect(card.get_by_test_id("v2-pending-action-send")).to_be_enabled()
+
+ page.evaluate(REQUEST_FOCUS, {"id": "act-hist", "conversationId": CONVERSATION_ID})
+ expect(card).to_be_focused()
+ expect(card).to_have_attribute("data-highlighted", "true")
+ scrolls = page.evaluate("() => window.scrollCalls")
+ assert len(scrolls) == 1
+ assert isinstance(scrolls[0]["top"], (int, float)) and scrolls[0]["top"] >= 0
+ assert scrolls[0]["behavior"] in ("smooth", "auto")
+
+ # The highlight is a moment's cue, not a permanent state, and focus stays where it was put.
+ page.wait_for_function("() => window.highlightLog.length >= 2", timeout=10000)
+ assert page.evaluate("() => window.highlightLog") == ["true", None]
+ expect(card).to_be_focused()
+ # The request was spent, so nothing scrolls or highlights again by itself.
+ assert page.evaluate(
+ "() => window.OrchHarness.stores.pendingActions.chatPendingActionsStore.getState().focusRequest"
+ ) is None
+ page.wait_for_timeout(300)
+ assert len(page.evaluate("() => window.scrollCalls")) == 1
+ assert not _posts_to_actions(page)
+ assert errors == []
+
+
+def test_deep_link_finds_a_card_that_only_exists_by_id(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ older = pending_action("act-old", subject="Older draft", request_id="req-old")
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(page, origin, messages=thread(), listed=[], by_id=[older])
+ _wait_for_list(page)
+ expect(root.get_by_test_id("v2-pending-action-card")).to_have_count(0)
+
+ page.evaluate(REQUEST_FOCUS, {"id": "act-old", "conversationId": CONVERSATION_ID})
+ card = root.locator("[data-pending-action-id='act-old']")
+ expect(card).to_have_count(1)
+ expect(card).to_be_focused()
+ expect(card).to_have_attribute("data-highlighted", "true")
+ expect(root.get_by_role("region", name=SECTION_NAME).locator("[data-pending-action-id='act-old']")).to_have_count(1)
+ expect(root.get_by_test_id("v2-pending-action-focus-unavailable")).to_have_count(0)
+ fetched = [r for r in _requests(page) if r["path"] == f"{LIST_PATH}/act-old"]
+ assert fetched and f"conversation_id={CONVERSATION_ID}" in fetched[0]["search"]
+ assert errors == []
+
+
+def test_deep_link_to_a_card_the_viewer_cannot_open_says_so(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ action = pending_action("act-hist", subject="Budget approval")
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(page, origin, messages=thread(action), listed=[action], by_id=[action])
+ expect(root.locator("[data-pending-action-id='act-hist']")).to_have_count(1)
+
+ page.evaluate(REQUEST_FOCUS, {"id": "act-gone", "conversationId": CONVERSATION_ID})
+ notice = root.get_by_test_id("v2-pending-action-focus-unavailable")
+ expect(notice).to_be_visible()
+ expect(notice).to_have_attribute("role", "status")
+ expect(notice).to_contain_text(UNAVAILABLE)
+ # The cards that are there stay usable, and nothing was highlighted by mistake.
+ expect(root.locator("[data-pending-action-id='act-hist']")).not_to_have_attribute("data-highlighted", "true")
+ asked = [r for r in _requests(page) if r["path"] == f"{LIST_PATH}/act-gone"]
+ assert asked and f"conversation_id={CONVERSATION_ID}" in asked[0]["search"]
+
+ notice.get_by_role("button", name="Dismiss").click()
+ expect(notice).to_have_count(0)
+ assert not _posts_to_actions(page)
+ assert errors == []
+
+
+def test_failed_list_is_shown_and_can_be_read_again(page):
+ _require_bundle()
+ errors = _collect_page_errors(page)
+ unclaimed = pending_action("act-orphan", subject="Unrelated draft", request_id="req-other")
+ with harness_build.start_static_server() as origin:
+ root = _open_chat(
+ page, origin, messages=thread(), listed=[unclaimed], by_id=[unclaimed], listing_fails=True,
+ )
+ error = root.get_by_test_id("v2-pending-actions-error")
+ expect(error).to_be_visible()
+ expect(root.get_by_role("region", name=SECTION_NAME)).to_be_visible()
+ expect(root.get_by_test_id("v2-pending-action-card")).to_have_count(0)
+
+ page.evaluate("() => { window.listFails = false; }")
+ root.get_by_test_id("v2-pending-actions-refresh").click()
+ expect(root.get_by_test_id("v2-pending-action-card")).to_have_count(1)
+ expect(root.locator("[data-pending-action-id='act-orphan']")).to_have_count(1)
+ expect(error).to_have_count(0)
+ assert not _posts_to_actions(page)
+ assert errors == []
+
+
+if __name__ == "__main__":
+ sys.exit(pytest.main([__file__, "-q"]))
diff --git a/ui_tests/test_v2_media_galleries.py b/ui_tests/test_v2_media_galleries.py
index a8ba24cd8..2e8afa099 100644
--- a/ui_tests/test_v2_media_galleries.py
+++ b/ui_tests/test_v2_media_galleries.py
@@ -35,6 +35,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -138,6 +139,9 @@ def handle(self, route):
if request.method == "GET" and parsed.path in self.assets:
route.fulfill(path=str(self.assets[parsed.path]))
return
+ if is_pending_actions_list(request.method, parsed.path):
+ route.fulfill(json=pending_actions_payload())
+ return
if parsed.path.startswith("/media/") and self.media(route, name):
return
if parsed.path == "/favicon.ico":
diff --git a/ui_tests/test_v2_new_chat_reset.py b/ui_tests/test_v2_new_chat_reset.py
index 131c820aa..b7b2ce603 100644
--- a/ui_tests/test_v2_new_chat_reset.py
+++ b/ui_tests/test_v2_new_chat_reset.py
@@ -38,6 +38,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -144,6 +145,9 @@ def handle(self, route):
if path == "/favicon.ico":
route.fulfill(status=204)
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
self.requests.append((request.method, path))
if path == "/api/create_conversation" and request.method == "POST":
if self.hold_create:
diff --git a/ui_tests/test_v2_notifications_bell.py b/ui_tests/test_v2_notifications_bell.py
index dd9ab49e4..ad2602b11 100644
--- a/ui_tests/test_v2_notifications_bell.py
+++ b/ui_tests/test_v2_notifications_bell.py
@@ -1,9 +1,10 @@
# test_v2_notifications_bell.py
"""
Browser regressions for the V2 notification bell, its panel and desktop notifications.
-Version: 0.261.251
+Version: 0.261.307
Implemented in: 0.261.195
Notices about a chat-started run's results, and workflow-activity links, open the run in V2: 0.261.251
+Notices about a Microsoft 365 pending action open its card in V2 chat, not classic chat: 0.261.307
Exercises the real rail, bell, panel, chat page, preferences tab, stores and notification
runtime, bundled by fixtures/notification_bell. Only HTTP answers and the browser APIs a
@@ -273,6 +274,31 @@ def m365_pending_action_notice(notice_id="n-m365-action", conversation_id="conv-
)
+def pending_action(action_id="act-1", conversation_id="conv-b", subject="Budget summary"):
+ """A saved outgoing email in the shape the server sends it."""
+ return {
+ "type": "msgraph_pending_action",
+ "id": action_id,
+ "version": "v1",
+ "status": "pending",
+ "operation": "send_mail",
+ "graph_resource_type": "mail",
+ "subject": subject,
+ "summary": {
+ "subject": subject,
+ "body_preview": "Hello Ann, here is the budget summary.",
+ "body_preview_truncated": False,
+ "to_recipients": ["ann@example.com"],
+ },
+ "can_cancel": True,
+ "can_send_now": True,
+ "viewer_is_owner": True,
+ "review_details_required": False,
+ "conversation_id": conversation_id,
+ "updated_at": "2026-10-07T16:00:00Z",
+ }
+
+
def document_notice(notice_id="n-doc", read=False):
return notice(
notice_id, "document_processing_complete", "Document ready: Q3 forecast.pdf",
@@ -347,6 +373,9 @@ def __init__(self, page, titles=None, *, streams=0):
self.errors = []
self.unexpected = []
self.expected_http_failures = set()
+ # Every saved Microsoft 365 action the server knows by id, and the ones its list leaves out.
+ self.pending_actions = {}
+ self.unlisted_pending_actions = set()
self._created = 0
# Server state -----------------------------------------------------------------------------
@@ -385,7 +414,10 @@ def handle(self, route):
path = parsed.path
self.requests.append((method, f"{path}?{parsed.query}" if parsed.query else path, request.resource_type))
query = {key: values[-1] for key, values in parse_qs(parsed.query).items()}
- for answer in (self.page_route, self.notification_route, self.conversation_route, self.other_route):
+ for answer in (
+ self.page_route, self.notification_route, self.conversation_route, self.pending_action_route,
+ self.other_route,
+ ):
if answer(route, method, path, query):
return
self.unexpected.append(f"{method} {path}")
@@ -459,6 +491,31 @@ def conversation_route(self, route, method, path, query):
return False
return True
+ def pending_action_route(self, route, method, path, query):
+ """The conversation's saved Microsoft 365 actions, and one action read by its id."""
+ if method != "GET":
+ return False
+ if path == "/api/msgraph/pending-actions":
+ route.fulfill(json={
+ "success": True,
+ "pending_actions": [
+ copy.deepcopy(record) for action_id, record in self.pending_actions.items()
+ if record["conversation_id"] == query.get("conversation_id")
+ and action_id not in self.unlisted_pending_actions
+ ],
+ "continuation_token": "",
+ })
+ elif match := re.fullmatch(r"/api/msgraph/pending-actions/([^/]+)", path):
+ record = self.pending_actions.get(unquote(match.group(1)))
+ if record is None:
+ self.expected_http_failures.add((path, 404))
+ route.fulfill(status=404, json={"success": False, "error": "Pending action not found."})
+ else:
+ route.fulfill(json={"success": True, "pending_action": copy.deepcopy(record)})
+ else:
+ return False
+ return True
+
def other_route(self, route, method, path, query):
if method == "GET" and re.fullmatch(r"/api/chat/stream/status/[^/]+", path):
route.fulfill(json={"active": False, "pending": False, "reattachable": False})
@@ -1433,11 +1490,6 @@ def test_classic_links_open_the_matching_v2_page(harness):
lambda: workflow_notice("n-classic", "The digest step timed out.", group_id="grp-9"),
"/elsewhere", "/workflow-activity?workflowId=wf-1&runId=run-1", [{"groupId": "grp-9"}], 200,
),
- # Only the classic chat page draws the pending-action card, so it is not opened in V2.
- "m365-pending-action": (
- lambda: m365_pending_action_notice("n-classic"),
- "/chat", "/chats?conversationId=conv-b&m365_pending_action=act-1", [], 200,
- ),
}
@@ -1464,6 +1516,122 @@ def test_notices_for_pages_v2_has_not_rebuilt_open_the_classic_page(harness, cas
assert harness.count_requests("GET", "/api/conversations/conv-b/kind") == 0
+# Microsoft 365 pending actions: V2 chat draws the card itself, so the notice stays in V2 -------
+
+PENDING_CARD = '[data-testid="v2-pending-action-card"][data-pending-action-id="act-1"]'
+PENDING_SECTION = "Microsoft 365 outgoing actions for this conversation"
+PENDING_UNAVAILABLE = (
+ "That Microsoft 365 action is not available in this conversation. "
+ "It may have been removed, or you may not have access to it."
+)
+
+# Records every change to a card's highlight, so a test sees it come and go however quickly it does.
+WATCH_HIGHLIGHT = r"""() => {
+ window.highlightLog = [];
+ new MutationObserver((records) => {
+ for (const record of records) {
+ window.highlightLog.push(record.target.getAttribute('data-highlighted'));
+ }
+ }).observe(document.body, {subtree: true, attributes: true, attributeFilter: ['data-highlighted']});
+}"""
+
+
+def open_pending_action_notice(harness, start, *, active="conv-a"):
+ harness.open(start, active=active)
+ harness.js(WATCH_HIGHLIGHT)
+ harness.open_panel()
+ harness.action("n-action", "open").click()
+
+
+def highlights(harness):
+ return harness.js("() => window.highlightLog")
+
+
+@pytest.mark.parametrize("start", ["/elsewhere", "/chat"])
+def test_a_pending_action_notice_opens_its_card_in_v2_chat(harness, start):
+ harness.pending_actions["act-1"] = pending_action()
+ harness.add(m365_pending_action_notice("n-action"))
+ open_pending_action_notice(harness, start)
+
+ card = harness.page.locator(PENDING_CARD)
+ expect(card).to_be_visible()
+ expect(current_route(harness)).to_have_text("/chat?conversationId=conv-b")
+ expect(harness.panel).to_have_count(0)
+ # The card is drawn in the chat, scrolled to, highlighted and focused for the keyboard.
+ expect(harness.page.get_by_role("region", name=PENDING_SECTION).locator(PENDING_CARD)).to_have_count(1)
+ expect(card).to_be_focused()
+ harness.wait_for(lambda: highlights(harness)[:1] == ["true"], "The card was not highlighted.")
+ harness.wait_for(lambda: harness.read_calls == ["n-action"], "Opening the notice should mark it read.")
+ assert harness.active_conversation() == "conv-b"
+ assert harness.set_active_calls == []
+ # V2 answers the notice itself: the classic page is never loaded, and the address no longer
+ # names the action, so reloading the chat does not scroll to the card a second time.
+ assert harness.page.url == f"{ORIGIN}/harness.html"
+ assert not harness.saw("GET", "/chats")
+ assert harness.saw("GET", "/api/msgraph/pending-actions?conversation_id=conv-b")
+ assert harness.saw("GET", "/api/get_messages?conversation_id=conv-b")
+ assert harness.count_requests("GET", "/api/conversations/conv-b/kind") == 1
+
+
+def test_a_pending_action_notice_for_the_conversation_on_screen_highlights_its_card_in_place(harness):
+ harness.pending_actions["act-1"] = pending_action()
+ harness.add(m365_pending_action_notice("n-action"))
+ harness.open("/chat", active="conv-b")
+ card = harness.page.locator(PENDING_CARD)
+ expect(card).to_be_visible()
+ expect(card).not_to_be_focused()
+ harness.js(WATCH_HIGHLIGHT)
+
+ harness.open_panel()
+ harness.action("n-action", "open").click()
+ expect(card).to_be_focused()
+ harness.wait_for(lambda: highlights(harness)[:1] == ["true"], "The card was not highlighted.")
+ harness.wait_for(lambda: harness.read_calls == ["n-action"], "Opening the notice should mark it read.")
+ # The conversation already on screen is left alone: no reload, and no lost stream.
+ assert harness.active_conversation() == "conv-b"
+ assert harness.count_requests("GET", "/api/get_messages") == 0
+ assert harness.count_requests("GET", "/api/conversations/conv-b/kind") == 0
+ assert harness.page.url == f"{ORIGIN}/harness.html"
+ expect(current_route(harness)).to_have_text("/chat?conversationId=conv-b")
+
+
+def test_a_pending_action_notice_finds_an_action_the_conversation_list_does_not_show(harness):
+ # The list is paged, so an older action is read by its id.
+ harness.pending_actions["act-1"] = pending_action()
+ harness.unlisted_pending_actions.add("act-1")
+ harness.add(m365_pending_action_notice("n-action"))
+ open_pending_action_notice(harness, "/elsewhere")
+
+ card = harness.page.locator(PENDING_CARD)
+ expect(card).to_be_visible()
+ expect(card).to_be_focused()
+ harness.wait_for(lambda: highlights(harness)[:1] == ["true"], "The card was not highlighted.")
+ expect(harness.page.get_by_test_id("v2-pending-action-focus-unavailable")).to_have_count(0)
+ assert harness.saw("GET", "/api/msgraph/pending-actions/act-1?conversation_id=conv-b")
+ assert harness.page.url == f"{ORIGIN}/harness.html"
+
+
+@pytest.mark.parametrize("start", ["/elsewhere", "/chat"])
+def test_a_pending_action_notice_for_an_action_that_is_gone_says_so_and_opens_the_conversation(harness, start):
+ harness.pending_actions["act-other"] = pending_action("act-other", subject="Another draft")
+ harness.add(m365_pending_action_notice("n-action", action_id="act-gone"))
+ open_pending_action_notice(harness, start)
+
+ unavailable = harness.page.get_by_test_id("v2-pending-action-focus-unavailable")
+ expect(unavailable).to_be_visible()
+ expect(unavailable).to_contain_text(PENDING_UNAVAILABLE)
+ expect(current_route(harness)).to_have_text("/chat?conversationId=conv-b")
+ # The conversation still opens with the cards it does have, none of them highlighted by mistake.
+ other = harness.page.locator('[data-pending-action-id="act-other"]')
+ expect(other).to_have_count(1)
+ expect(other).not_to_have_attribute("data-highlighted", "true")
+ harness.wait_for(lambda: harness.read_calls == ["n-action"], "Opening the notice should mark it read.")
+ assert highlights(harness) == []
+ assert harness.saw("GET", "/api/msgraph/pending-actions/act-gone?conversation_id=conv-b")
+ assert harness.page.url == f"{ORIGIN}/harness.html"
+ assert harness.set_active_calls == []
+
+
# (notice id, link, link context, what the panel says instead of opening it)
UNSAFE_LINKS = [
("n-off-site", "https://evil.example/phish", {}, OFF_SITE),
diff --git a/ui_tests/test_v2_orchestration_actions.py b/ui_tests/test_v2_orchestration_actions.py
index 84de1ea67..8fc3f0d60 100644
--- a/ui_tests/test_v2_orchestration_actions.py
+++ b/ui_tests/test_v2_orchestration_actions.py
@@ -26,6 +26,7 @@
import harness_build as hb
from v2_admin_settings import connect_options
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload
pytestmark = pytest.mark.ui
@@ -58,6 +59,10 @@ def serve(route):
route.fulfill(path=str(hb.BUNDLE), content_type="application/javascript")
elif f"{parsed.scheme}://{parsed.netloc}" == ORIGIN and parsed.path == "/favicon.ico":
route.fulfill(status=204)
+ elif f"{parsed.scheme}://{parsed.netloc}" == ORIGIN and is_pending_actions_list(
+ route.request.method, parsed.path
+ ):
+ route.fulfill(json=pending_actions_payload())
else:
unexpected_requests.append(route.request.url)
route.abort()
diff --git a/ui_tests/test_v2_orchestration_approval_persistence.py b/ui_tests/test_v2_orchestration_approval_persistence.py
index 1d3c906b7..86cf47fba 100644
--- a/ui_tests/test_v2_orchestration_approval_persistence.py
+++ b/ui_tests/test_v2_orchestration_approval_persistence.py
@@ -29,6 +29,7 @@
import harness_build as hb # noqa: E402
from playwright_connection import connect_options # noqa: E402, F401
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -158,6 +159,8 @@ def handle(self, route):
self.respond(route, {"messages": []})
elif path == "/api/conversations/feed" and request.method == "GET":
self.respond(route, {"conversations": [], "has_more": False})
+ elif is_pending_actions_list(request.method, path):
+ self.respond(route, pending_actions_payload())
else:
self.unexpected.append(f"{request.method} {path}")
self.respond(route, {"error": "Unexpected approval fixture request"}, 404)
diff --git a/ui_tests/test_v2_orchestration_plan_editor.py b/ui_tests/test_v2_orchestration_plan_editor.py
index 081dec881..747d9bf59 100644
--- a/ui_tests/test_v2_orchestration_plan_editor.py
+++ b/ui_tests/test_v2_orchestration_plan_editor.py
@@ -40,6 +40,7 @@
# These shared helpers live outside the test module's import directory.
import harness_build as hb # noqa: E402
from playwright_connection import connect_options # noqa: E402, F401
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -301,6 +302,11 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ # The chat reads a conversation's saved Microsoft 365 actions whenever one is open. Answered
+ # before the request is recorded, so a suite about something else counts only its own.
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
body = request.post_data_json if request.method == "POST" else {}
self.requests.append({"path": path, "method": request.method, "body": body, "query": parsed.query})
if path == RUN:
diff --git a/ui_tests/test_v2_orchestration_planning_retry.py b/ui_tests/test_v2_orchestration_planning_retry.py
index 5a4a3f792..2f92afaef 100644
--- a/ui_tests/test_v2_orchestration_planning_retry.py
+++ b/ui_tests/test_v2_orchestration_planning_retry.py
@@ -23,6 +23,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -82,6 +83,9 @@ def handle(self, route):
if path == "/favicon.ico":
route.fulfill(status=204)
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
body = request.post_data_json if request.post_data else None
self.requests.append({"path": path, "method": request.method, "body": copy.deepcopy(body)})
if path == PLAN:
diff --git a/ui_tests/test_v2_orchestration_recovery.py b/ui_tests/test_v2_orchestration_recovery.py
index 46862d662..29af02586 100644
--- a/ui_tests/test_v2_orchestration_recovery.py
+++ b/ui_tests/test_v2_orchestration_recovery.py
@@ -27,6 +27,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -166,6 +167,9 @@ def stream(route, events):
def handle(self, route):
request = route.request
path = urlsplit(request.url).path
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
body = request.post_data_json if request.post_data else None
self.requests.append({"path": path, "method": request.method, "body": body})
if request.method == "GET" and path in self.assets:
diff --git a/ui_tests/test_v2_orchestration_streaming_bubble.py b/ui_tests/test_v2_orchestration_streaming_bubble.py
index 68d909b1d..f929c4867 100644
--- a/ui_tests/test_v2_orchestration_streaming_bubble.py
+++ b/ui_tests/test_v2_orchestration_streaming_bubble.py
@@ -40,6 +40,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -183,6 +184,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
if path == "/favicon.ico":
route.fulfill(status=204)
return
diff --git a/ui_tests/test_v2_orchestration_workflow_handoff_card.py b/ui_tests/test_v2_orchestration_workflow_handoff_card.py
index 13bbe13b1..6279d1d9f 100644
--- a/ui_tests/test_v2_orchestration_workflow_handoff_card.py
+++ b/ui_tests/test_v2_orchestration_workflow_handoff_card.py
@@ -54,6 +54,7 @@
import functions_workflow_editor # noqa: E402 (the editor options the server sends)
import functions_workflow_handoff_builder as builder # noqa: E402 (the disclosure the card shows)
from test_support.versioning import assert_app_version_at_least # noqa: E402
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
def server_literal(module, name):
@@ -395,6 +396,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
query = parse_qs(parsed.query)
body = request.post_data_json if request.method == "POST" else None
self.requests.append({"method": request.method, "path": path, "query": query, "body": body})
diff --git a/ui_tests/test_v2_orchestration_workflow_proposal_card.py b/ui_tests/test_v2_orchestration_workflow_proposal_card.py
index 9e40e4c6d..ae15b1512 100644
--- a/ui_tests/test_v2_orchestration_workflow_proposal_card.py
+++ b/ui_tests/test_v2_orchestration_workflow_proposal_card.py
@@ -49,6 +49,7 @@
import functions_workflow_schedules # noqa: E402 (the server's own schedule choices)
import functions_workflow_result_masking as masking # noqa: E402 (the result descriptor's version)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -291,6 +292,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
query = parse_qs(parsed.query)
body = request.post_data_json if request.method == "POST" else None
self.requests.append({"method": request.method, "path": path, "query": query, "body": body})
diff --git a/ui_tests/test_v2_orchestration_workflow_run_links.py b/ui_tests/test_v2_orchestration_workflow_run_links.py
index 284e4a649..21a499562 100644
--- a/ui_tests/test_v2_orchestration_workflow_run_links.py
+++ b/ui_tests/test_v2_orchestration_workflow_run_links.py
@@ -44,6 +44,7 @@
from test_support.versioning import assert_app_version_at_least # noqa: E402
from ui_tests.fixtures.workflow_editor import WORKFLOW_ID, WorkflowEditorFixture # noqa: E402
from ui_tests.fixtures.workspace_authoring import ORIGIN as SPA_ORIGIN # noqa: E402
+from ui_tests.fixtures.v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -108,6 +109,9 @@ def handle(self, route):
if request.method == "GET" and path in self.assets:
route.fulfill(path=str(self.assets[path]))
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
if path == LINKS_PATH and request.method == "GET":
self.reads.append(parsed.query)
if parsed.query != f"conversation_id={CONVERSATION}":
diff --git a/ui_tests/test_v2_prompt_composer_experience.py b/ui_tests/test_v2_prompt_composer_experience.py
index 7ae89783f..3c565dc6b 100644
--- a/ui_tests/test_v2_prompt_composer_experience.py
+++ b/ui_tests/test_v2_prompt_composer_experience.py
@@ -46,6 +46,7 @@
from functions_prompt_metadata import build_prompt_selection_metadata # noqa: E402
from test_v2_chat_context_selection import ContextApi, open_picker, pick_context # noqa: E402
from v2_admin_settings import connect_options # noqa: E402, F401
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -139,6 +140,9 @@ def handle(self, route: Route):
if path == "/favicon.ico":
route.fulfill(status=204)
return
+ if is_pending_actions_list(request.method, path):
+ route.fulfill(json=pending_actions_payload())
+ return
self.requests.append((request.method, path))
if request.method == "POST" and path == FILL_PATH:
diff --git a/ui_tests/test_v2_sidebar_conversation_scroll.py b/ui_tests/test_v2_sidebar_conversation_scroll.py
index 38bc877df..e09c9b0e5 100644
--- a/ui_tests/test_v2_sidebar_conversation_scroll.py
+++ b/ui_tests/test_v2_sidebar_conversation_scroll.py
@@ -36,6 +36,7 @@
editor_assets,
editor_browser,
)
+from v2_pending_action_stubs import is_pending_actions_list, pending_actions_payload # noqa: E402
pytestmark = pytest.mark.ui
@@ -198,6 +199,8 @@ def answer_open(self, route, method, path):
route.fulfill(json={"approvals": []})
elif method == "GET" and path == "/api/user/collaboration-suggestions":
route.fulfill(json={"results": []})
+ elif is_pending_actions_list(method, path):
+ route.fulfill(json=pending_actions_payload())
else:
return False
return True