diff --git a/application/single_app/config.py b/application/single_app/config.py index 10d13658b..9d6d17910 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.304" +VERSION = "0.261.305" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_m365_citations.py b/application/single_app/functions_m365_citations.py index 70d67d1b0..cfa616fc5 100644 --- a/application/single_app/functions_m365_citations.py +++ b/application/single_app/functions_m365_citations.py @@ -1,14 +1,15 @@ # functions_m365_citations.py """Microsoft 365 emails, calendar events and files as first-class answer citations. -Version: 0.261.303 +Version: 0.261.305 Implemented in: 0.261.303 +Direct-answer guidance refined in: 0.261.305 An item an answer draws on is normalized into a small citation record with a deterministic id. Tool results carry a model-facing citation value in SimpleChat's inline marker grammar, ``(Source: , Location: <where>) [#<id>]``, so the model cites an email, event or file the same way it cites a workspace document. The records travel with the assistant message and the -conversation, so the browser draws citation chips, source cards and "Open online" links without +conversation, so the browser draws citation chips, source cards and online links without calling Microsoft 365 again. Rules every record follows: @@ -60,7 +61,16 @@ MAX_MARKER_TITLE_CHARS = 120 MAX_SOURCES_NOTE_ITEMS = 50 +M365_ANSWER_STYLE_INSTRUCTIONS = ( + "For Microsoft 365 items, answer the user's question directly without routine source-provenance " + 'introductions such as "Confirmed from the SharePoint document". Citations already identify the ' + 'sources. Name a source or platform when the user asks about provenance or when needed to ' + 'distinguish conflicting evidence. Preserve uncertainty, missing evidence and partial-coverage ' + 'disclosures.' +) + M365_CITATION_INSTRUCTIONS = ( + f"{M365_ANSWER_STYLE_INSTRUCTIONS} " 'Cite each email, event or file you mention by copying its "citation" value verbatim right ' 'after the line or sentence about it. Do not invent, shorten, reformat or renumber citation ' 'values, and do not cite an item you did not use.' @@ -972,7 +982,8 @@ def build_m365_sources_note(records): return "" return ( "Microsoft 365 sources (copy each citation value verbatim right after the claim or list line it " - "supports; never invent or alter one):\n" + "\n".join(lines) + "supports; never invent or alter one):\n" + f"{M365_ANSWER_STYLE_INSTRUCTIONS}\n\n" + "\n".join(lines) ) diff --git a/application/single_app/functions_mixed_source_orchestration.py b/application/single_app/functions_mixed_source_orchestration.py index 9a45a71a1..22865b843 100644 --- a/application/single_app/functions_mixed_source_orchestration.py +++ b/application/single_app/functions_mixed_source_orchestration.py @@ -18,6 +18,7 @@ strict_source_authority_enabled, ) from content_screening.contracts import SCREENING_FIELD, ScreeningError, SourceAuthorityUnverifiedError +from functions_m365_citations import M365_ANSWER_STYLE_INSTRUCTIONS def log_event(*args, **kwargs): """Lazily resolve telemetry logging to avoid module-level import cycles.""" @@ -2108,6 +2109,7 @@ def build_mixed_source_evidence_handoff( "action to obtain it and reason over the handoff and the action results together before answering. " "Never derive numeric conclusions from an indexed preview of a tabular source whose evidence status " "is not completed; obtain those values from a computed tabular result instead. " + f"{M365_ANSWER_STYLE_INSTRUCTIONS} " f"{partial_coverage_instruction}\n\n{serialized_payload}" ), "mixed_source_coverage": coverage, diff --git a/application/single_app/functions_orchestration_composition.py b/application/single_app/functions_orchestration_composition.py index 7c459d9fb..0ebb547df 100644 --- a/application/single_app/functions_orchestration_composition.py +++ b/application/single_app/functions_orchestration_composition.py @@ -1,7 +1,7 @@ # functions_orchestration_composition.py """Explicit one-call content preparation from named authorized result readers. -Version: 0.261.293 +Version: 0.261.305 Refusals for a missing signed-in session reported as their own failure in: 0.261.209 Charts drawn upstream reach the model as their placement tokens in: 0.261.293 No retrieval, file-format inference, upload, publication, or implicit sibling inputs. @@ -18,6 +18,7 @@ When an input carries citation values, such as the Microsoft 365 sources an action step lists, the step is told to copy them verbatim after the claims they support (0.261.303). +Microsoft 365 answers avoid routine provenance introductions without losing disclosures (0.261.305). """ import json @@ -29,6 +30,7 @@ from content_screening.contracts import ScreeningError from functions_appinsights import log_event, workflow_log_context from functions_chart_operations import normalize_inline_chart_markdown +from functions_m365_citations import M365_ANSWER_STYLE_INSTRUCTIONS from functions_mixed_source_orchestration import MixedSourceCancellationError from functions_orchestration_context import conversation_reference_messages from functions_orchestration_deliverables import ( @@ -112,7 +114,8 @@ 'copy its citation value verbatim right after the sentence or list line it supports. Never ' 'invent, shorten, renumber or alter a citation value, and never cite an item you did not use. ' "When you list Microsoft 365 emails, events or files, follow the layout in that input's " - 'presentation field: one numbered line per item, each ending with its citation value.' + 'presentation field: one numbered line per item, each ending with its citation value. ' + f'{M365_ANSWER_STYLE_INSTRUCTIONS}' ) # A citation value as it appears inside the serialized inputs. _CITATION_VALUE_RE = re.compile(r'\(Source:.{1,400}?\)\s*\[#') diff --git a/application/single_app/semantic_kernel_plugins/msgraph_plugin.py b/application/single_app/semantic_kernel_plugins/msgraph_plugin.py index b774e9619..f45bd5b30 100644 --- a/application/single_app/semantic_kernel_plugins/msgraph_plugin.py +++ b/application/single_app/semantic_kernel_plugins/msgraph_plugin.py @@ -300,7 +300,7 @@ def metadata(self) -> Dict[str, Any]: { "name": "select_fields", "type": "str", - "description": "Optional comma-separated Graph fields to include.", + "description": "Optional comma-separated Graph fields to include. Source-card metadata and webLink are always requested.", "required": False, }, { @@ -360,7 +360,7 @@ def metadata(self) -> Dict[str, Any]: { "name": "select_fields", "type": "str", - "description": "Optional comma-separated Graph fields to include.", + "description": "Optional comma-separated Graph fields to include. Source-card metadata and webLink are always requested.", "required": False, }, { @@ -1530,7 +1530,9 @@ def get_my_events( str, "End of the time range as an ISO 8601 date or date and time. Requires start_datetime; a date alone includes that whole UTC day.", ] = "", - select_fields: Annotated[str, "Optional comma-separated Graph event fields to include."] = "", + select_fields: Annotated[ + str, "Optional comma-separated Graph event fields. Source-card metadata and webLink are always requested.", + ] = "", query: Annotated[ str, "Optional plain words that must all appear in the subject, location, organizer, attendees, categories, or description.", @@ -1576,10 +1578,14 @@ def get_my_events( return self._invalid_parameter_error(operation_name, "start_datetime must be earlier than end_datetime.") normalized_top = self._normalize_top(top) + citation_select, _ = self._merge_select_fields( + select_fields or self.DEFAULT_EVENT_SELECT, self.DEFAULT_EVENT_SELECT.split(","), + ) scan_fields = ["start"] if terms: scan_fields += ["subject", "location", "organizer", "attendees", "categories", "bodyPreview"] - select, added_fields = self._merge_select_fields(select_fields or self.DEFAULT_EVENT_SELECT, scan_fields) + # Only query-only fields may be stripped before the source card is captured. + select, added_fields = self._merge_select_fields(citation_select, scan_fields) params, headers = self._build_odata_params( top=normalized_top, select_fields=select, @@ -1875,7 +1881,9 @@ def get_my_messages( "Folder to read: inbox, sentitems, drafts, deleteditems, archive, junkemail, a folder id, or all for every folder.", ] = "inbox", unread_only: Annotated[bool, "If true, only unread messages are returned."] = False, - select_fields: Annotated[str, "Optional comma-separated Graph message fields to include."] = "", + select_fields: Annotated[ + str, "Optional comma-separated Graph message fields. Source-card metadata and webLink are always requested.", + ] = "", search: Annotated[ str, "Optional plain words to find in the sender, subject, or body. Every word must match; operators and field prefixes are ignored.", @@ -1914,7 +1922,7 @@ def get_my_messages( path = f"/v1.0/me/mailFolders/{quote(normalized_folder, safe='')}/messages" default_select = self.DEFAULT_MESSAGE_SELECT + (",bodyPreview" if terms else "") - required_fields = ["receivedDateTime"] + (["isRead"] if normalized_unread else []) + required_fields = self.DEFAULT_MESSAGE_SELECT.split(",") select, _ = self._merge_select_fields(select_fields or default_select, required_fields) params, headers = self._build_odata_params(top=normalized_top, select_fields=select) scan: Dict[str, Any] = {"last": None} diff --git a/application/v2_ui/src/components/chat/ConversationDrawer.tsx b/application/v2_ui/src/components/chat/ConversationDrawer.tsx index bacc967e5..2d4582349 100644 --- a/application/v2_ui/src/components/chat/ConversationDrawer.tsx +++ b/application/v2_ui/src/components/chat/ConversationDrawer.tsx @@ -200,8 +200,8 @@ function M365ItemRow({ item }: { item: UsedM365Item }) { </div> <M365OpenLink record={item} - label="Open online" - className="inline-flex shrink-0 items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-accent transition-colors hover:bg-surface-2" + label={item.kind === 'file' ? 'Open online' : undefined} + className="inline-flex shrink-0 items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-accent-hover transition-colors hover:bg-surface-2 dark:text-accent" /> </div> </li> diff --git a/application/v2_ui/src/components/chat/M365CitationChip.tsx b/application/v2_ui/src/components/chat/M365CitationChip.tsx index 2d8d7a114..ce038a364 100644 --- a/application/v2_ui/src/components/chat/M365CitationChip.tsx +++ b/application/v2_ui/src/components/chat/M365CitationChip.tsx @@ -10,7 +10,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { clsx } from 'clsx'; import { CalendarDays, ExternalLink, FileText, Mail, TriangleAlert, X } from 'lucide-react'; -import { GlassPanel } from '../ui/primitives'; +import { Modal } from '../ui/Modal'; import { m365ChipLabel, m365DetailRows, @@ -81,38 +81,23 @@ function M365SourceCard({ useEffect(() => { closeRef.current?.focus(); - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - onClose(); - } - }; - document.addEventListener('keydown', onKeyDown); - return () => document.removeEventListener('keydown', onKeyDown); - }, [onClose]); + }, []); const rows = record ? m365DetailRows(record) : []; const title = record ? record.title : fallbackTitle || 'Microsoft 365 source'; return ( - <div - className="fixed inset-0 z-50 flex items-center justify-center p-4" - role="dialog" - aria-modal="true" - aria-label="Microsoft 365 source" - > - <div className="absolute inset-0 bg-black/40" aria-hidden="true" onClick={onClose} /> - - <GlassPanel - elevation="modal" - edge - className="relative flex max-h-[70vh] w-full max-w-lg flex-col" - > + <Modal + title="Microsoft 365 source" + onClose={onClose} + bodyClassName="overflow-y-auto px-5 py-4" + banner={( <div className="flex shrink-0 items-start gap-3 border-b border-edge px-5 py-3.5"> <M365KindIcon kind={record?.kind} size={17} className="mt-0.5 shrink-0 text-text-3" /> <div className="min-w-0 flex-1"> <h2 className="break-words text-sm font-semibold text-text-1">{title}</h2> {record?.location_label && ( - <p className="text-xs text-text-3">{record.location_label}</p> + <p className="text-xs text-text-2">{record.location_label}</p> )} </div> <button @@ -125,58 +110,59 @@ function M365SourceCard({ <X size={17} /> </button> </div> - - <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4"> - {record ? ( - rows.length > 0 ? ( - <dl className="space-y-2"> - {rows.map((row) => ( - <div key={row.label} className="flex gap-3 text-sm"> - <dt className="w-24 shrink-0 text-text-3">{row.label}</dt> - <dd className="min-w-0 flex-1 break-words text-text-1">{row.value}</dd> - </div> - ))} - </dl> - ) : ( - <p className="text-sm text-text-3">No further details were recorded for this item.</p> - ) - ) : ( - <p className="flex items-start gap-2 text-sm text-text-2"> - <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" /> - This Microsoft 365 source is no longer available in this conversation. - </p> - )} - </div> - - {record && safeM365Url(record.web_url) && ( - <div className="flex shrink-0 justify-end border-t border-edge px-5 py-3"> - <M365OpenLink - record={record} - className="inline-flex h-8 items-center gap-1.5 rounded-xl bg-accent px-3 text-sm font-medium text-on-accent shadow-sm transition-colors hover:bg-accent-hover" - /> - </div> - )} - </GlassPanel> - </div> + )} + footer={record ? ( + safeM365Url(record.web_url) ? ( + <M365OpenLink + record={record} + className={clsx( + 'inline-flex h-8 items-center gap-1.5 rounded-xl px-3 text-sm font-medium text-on-accent shadow-sm transition-colors', + 'bg-accent-hover hover:underline dark:bg-accent dark:hover:bg-accent-hover', + )} + /> + ) : ( + <p className="text-sm text-text-2"> + No online link is available for this source. Recall the item again to refresh its details. + </p> + ) + ) : undefined} + > + {record ? ( + rows.length > 0 ? ( + <dl className="space-y-2"> + {rows.map((row) => ( + <div key={row.label} className="flex gap-3 text-sm"> + <dt className="w-24 shrink-0 text-text-2">{row.label}</dt> + <dd className="min-w-0 flex-1 break-words text-text-1">{row.value}</dd> + </div> + ))} + </dl> + ) : ( + <p className="text-sm text-text-2">No further details were recorded for this item.</p> + ) + ) : ( + <p className="flex items-start gap-2 text-sm text-text-2"> + <TriangleAlert size={15} className="mt-0.5 shrink-0 text-warn" aria-hidden="true" /> + This Microsoft 365 source is no longer available in this conversation. + </p> + )} + </Modal> ); } function M365Chip({ citation, fallbackLabel }: { citation: ParsedCitation; fallbackLabel: string }) { const record = useM365Citation(citation.citationId); const [open, setOpen] = useState(false); - const chipRef = useRef<HTMLButtonElement>(null); const label = record ? m365ChipLabel(record) : fallbackLabel || 'Microsoft 365 source'; const where = record?.location_label; const close = useCallback(() => { setOpen(false); - chipRef.current?.focus(); }, []); return ( <> <button - ref={chipRef} type="button" onClick={() => setOpen(true)} title={where ? `${label} \u2014 ${where}` : label} diff --git a/application/v2_ui/src/components/chat/MessageInspector.tsx b/application/v2_ui/src/components/chat/MessageInspector.tsx index 8f1311a3e..b5d4ba15d 100644 --- a/application/v2_ui/src/components/chat/MessageInspector.tsx +++ b/application/v2_ui/src/components/chat/MessageInspector.tsx @@ -275,8 +275,8 @@ function SourcesSection({ sources }: { sources: MessageSources }) { </span> <M365OpenLink record={record} - label="Open online" - className="inline-flex shrink-0 items-center gap-1 text-accent hover:underline" + label={record.kind === 'file' ? 'Open online' : undefined} + className="inline-flex shrink-0 items-center gap-1 text-accent-hover hover:underline dark:text-accent" /> </li> ); diff --git a/docs/explanation/features/M365_SOURCE_CITATIONS.md b/docs/explanation/features/M365_SOURCE_CITATIONS.md index 0e5a6d0e1..0d5e43a8a 100644 --- a/docs/explanation/features/M365_SOURCE_CITATIONS.md +++ b/docs/explanation/features/M365_SOURCE_CITATIONS.md @@ -1,11 +1,12 @@ -# Microsoft 365 Source Citations (v0.261.303) +# Microsoft 365 Source Citations (v0.261.305) ## Overview An answer that uses SharePoint or OneDrive files, emails or calendar events now cites them the way it cites workspace documents. Each item the answer mentions gets an inline citation chip, and every cited item, plus every file whose content was read, is listed in the conversation's -**Documents** pane with an **Open online** link. Before this release an orchestrated SharePoint +**Documents** pane with **Open online** for files and **Open in Outlook** for emails and events. +Before source citations, an orchestrated SharePoint answer ended with plain text such as "Source: *EVA Swab Tool…* (SharePoint file **20170010188.pdf**)" with no chip and nothing in the Documents pane, and an email list changed its layout from one answer to the next. @@ -16,19 +17,28 @@ What changes for the reader: subject, with an icon for the kind of item. Clicking a chip opens a source card with the item's details and an **Open in SharePoint**, **Open in OneDrive** or **Open in Outlook** link. The link opens the item where it lives, so the reader's own sign-in decides what they - can see; nothing is downloaded or copied. + can see; nothing is downloaded or copied. The source card stays readable in light and dark + themes, keeps keyboard focus inside, and returns focus to the chip when closed. +- **Direct answers.** Model guidance avoids routine introductions such as "Confirmed from the + SharePoint document". Citations already identify the sources. Source names remain appropriate + when the question is about provenance or conflicting evidence, and uncertainty or incomplete + coverage must still be disclosed. - **One email and event layout.** Email and event lists always use the same layout: a header line such as "10 most recent emails, all unread, newest first:", then one numbered line per item with the subject in bold, the sender or time span, and the citation chip. Times are shown in the reader's browser time zone. - **Documents pane.** New **SharePoint & OneDrive**, **Email** and **Calendar** sections list the - conversation's Microsoft 365 items with an **Open online** link. The workspace document list - is unchanged. + conversation's Microsoft 365 items. Email and calendar rows offer **Open in Outlook**, which + opens the specific recalled item in a new browser tab. File rows keep **Open online**, and the + workspace document list is unchanged. The layout is a fixed text format that the model writes and the application cites, not a card the application draws, so it reads the same in copied text, exports and both chat interfaces. Implemented in version: **0.261.303**, tracked in `application/single_app/config.py`. +Source-card readability, Outlook metadata and answer guidance refined in version: +**0.261.305**, tracked by the same configuration version. See the +[citation modal and Outlook links fix]({{ '/explanation/fixes/M365_CITATION_MODAL_AND_OUTLOOK_LINKS_FIX/' | relative_url }}). Dependencies: @@ -80,6 +90,14 @@ plugin logger, the citation tracker and the routes can all use it. `prepare_file`, `read_file`, `read_file_chunk` and `analyze_file` results. This happens at the tool boundary, so captures saved before this release are cited too. + A custom `select_fields` value does not remove baseline source-card fields. Mail always + requests `id`, `subject`, `from`, `receivedDateTime`, `isRead`, `importance` and `webLink`; + calendar always requests `id`, `subject`, `start`, `end`, `location`, `organizer`, + `isAllDay` and `webLink`. Other requested fields remain available. Calendar fields added + only for keyword matching are removed before annotation, but baseline citation metadata + and explicitly requested fields are retained. This requires no additional Graph call + or permission. + The `presentation` value states the fixed layout: | Item | Line | @@ -121,6 +139,11 @@ plugin logger, the citation tracker and the routes can all use it. event carry `m365_citations`. When a run republishes its answer into a shared conversation, the shared copy takes the new records, and loses them if the republished answer is blocked. + Shared Microsoft 365 answer-style guidance travels with direct tool results, sources notes, + the trusted compose policy and mixed-source handoffs. It asks for direct answers without + routine provenance introductions while preserving source distinctions, missing evidence + and partial-coverage disclosures. Existing answer text is not post-processed or rewritten. + 6. **Conversation aggregate.** `used_m365_items` on the conversation lists every cited item and every file whose content was read, most recent first, with the message ids that used each item and `last_used_at`. It holds at most 200 items and drops the least recent first. It is @@ -176,7 +199,8 @@ agent, or let orchestration use one, and its answers are cited. 2. Click a chip. The source card shows From, Received, Read or Unread, importance and a preview, with **Open in Outlook**. 3. Open **Documents** in the conversation drawer. The **Email** section lists each cited email - with the sender, received time and **Open online**. + with the sender, received time and **Open in Outlook**. Recalled events have the same action + under **Calendar**. 4. Ask a question about a SharePoint document. The answer cites the file after each claim, and the **SharePoint & OneDrive** section lists it with its location and modified date. @@ -185,6 +209,12 @@ available in this conversation. Neither interface calls the workspace citation e Microsoft 365 id. The classic chat shows the citation's title as a link to the item, or as text when no `https` link was recorded. +An existing record without a safe online URL still shows its saved details. Its V2 source card +explains that an online link is unavailable and suggests recalling the item again to refresh +the details. Old messages and citation records are not migrated or rewritten; only new recalls +can capture a missing Graph-provided URL. The per-message **Sources** panel uses the same +**Open in Outlook** label for email and calendar items. + ## Testing and validation - `functional_tests/test_m365_source_citations.py`: deterministic ids, marker parsing with the @@ -195,11 +225,16 @@ when no `https` link was recorded. flags, the per-message limit keeping cited and read items, persistence sites and final events, workflow answers and their mirrored copies, the orchestration sources note, final message, done event and shared-copy refresh, the compose instruction, aggregate merge, cap, rebuild and - owner filter, history-sharing detection, and collaboration and export carry. + owner filter, history-sharing detection, and collaboration and export carry. Custom Graph + selections, keyword scans, mail pagination/continuation and shared direct-answer guidance + have regressions in **0.261.305**. - `ui_tests/test_v2_m365_source_citations.py`: V2 chips from a streamed answer, source cards and their links, the missing-record card, the request's time zone, the Documents pane sections at desktop and phone widths, and the classic chat's links, with no request to - `/api/get_citation`. + `/api/get_citation`. Production CSS checks enforce at least **4.5:1** source text/action + contrast in both themes, a dialog outside the Markdown subtree, contained/restored keyboard + focus, wrapping metadata, honest unavailable-link states and exact new-tab Outlook targets + from source cards, Documents and Sources. ### Known limitations @@ -208,6 +243,8 @@ when no `https` link was recorded. Orchestrated answers use UTC unless workflow proposals or results are configured. - An "Open in Outlook" link opens the owner's mailbox, so it only works for the owner. File links follow SharePoint and OneDrive permissions. +- A historic record without a Graph URL cannot reconstruct one from its hashed citation id. + Recall the item again; a link is shown only if Microsoft Graph supplies a safe URL. - Shared conversations show chips on messages but no Microsoft 365 items in the Documents pane. - Follow-up questions about a saved workflow result are answered from the stored result rather than from Microsoft 365, so those answers carry no Microsoft 365 records of their own. diff --git a/docs/explanation/fixes/M365_CITATION_MODAL_AND_OUTLOOK_LINKS_FIX.md b/docs/explanation/fixes/M365_CITATION_MODAL_AND_OUTLOOK_LINKS_FIX.md new file mode 100644 index 000000000..ab42fc24a --- /dev/null +++ b/docs/explanation/fixes/M365_CITATION_MODAL_AND_OUTLOOK_LINKS_FIX.md @@ -0,0 +1,100 @@ +# Microsoft 365 Citation Modal and Outlook Links Fix (v0.261.305) + +Fixed in version: **0.261.305**. +`application/single_app/config.py` advances from **0.261.304** to **0.261.305**. + +## Issue + +Microsoft 365 citation chips and SharePoint links worked, but the source popup's +action text could disappear against its background. Email and calendar recalls +could lose the metadata needed to open the specific item in Outlook on the web. +Answers also used unnecessary introductions such as "Confirmed from the +SharePoint document" even though the citation identified the source. + +## Root cause + +The V2 source popup lived inside the answer's Markdown subtree. Descendant link +styling could override the action's foreground with the same accent used for its +background. A production-CSS regression reproduced **1.00:1** action contrast in +light mode; the old browser tests did not load that stylesheet. + +Custom Graph `select_fields` replaced baseline mail/calendar fields, including +`webLink`. Calendar keyword cleanup could also remove fields introduced for +scanning that were needed for citation identity or source-card details. + +The unwanted prose was generated by the model, not a hardcoded UI label. +Citation-copy instructions did not include shared guidance for answering directly +without routine source-provenance introductions. + +## Technical changes + +| File | Change | +| --- | --- | +| `semantic_kernel_plugins/msgraph_plugin.py` | Merges baseline mail/calendar citation fields into custom selections. Calendar scan-only additions are tracked separately, so cleanup preserves baseline and caller-requested fields. | +| `functions_m365_citations.py` | Adds shared, Microsoft 365-scoped direct-answer guidance to tool citation instructions and orchestration source notes, retaining the sources-note heading and marker grammar. | +| `functions_orchestration_composition.py`, `functions_mixed_source_orchestration.py` | Carries the same guidance into trusted composition and mixed-source handoffs without weakening citations or coverage disclosures. | +| `v2_ui/src/components/chat/M365CitationChip.tsx` | Reuses the shared portaled `Modal`, isolating the card from Markdown styles and providing focus containment, Escape/backdrop dismissal and focus restoration. Uses readable local theme tokens and explains unavailable links. | +| `v2_ui/src/components/chat/ConversationDrawer.tsx`, `MessageInspector.tsx` | Uses **Open in Outlook** for email/calendar items and retains **Open online** for files, with readable link colors in both themes. | +| `functional_tests/test_m365_source_citations.py`, `test_m365_mail_calendar_search.py` | Exercises real plugin code with selection-aware Graph responses, pagination/continuation and prompt propagation. | +| `ui_tests/test_v2_m365_source_citations.py`, `ui_tests/fixtures/color_contrast.py`, `test_v2_workflow_change_tracking.py` | Loads production CSS and shares the existing composited-color contrast measurement instead of duplicating it. Covers themes, phone/desktop layouts, keyboard behavior, literal metadata, unavailable URLs and exact Outlook destinations. | + +Only validated HTTPS URLs supplied by Microsoft Graph become links. The shared +link component retains `target="_blank"` and `rel="noopener noreferrer"`. +No Graph lookup, additional permission, route, container or history migration is +introduced. + +## Reader behavior + +Click a citation chip to inspect its saved source details. **Open in Outlook** +opens the recalled email or calendar event in a new tab; the same action is +available in **Documents** and the reply's **Sources** panel. SharePoint and +OneDrive destinations are unchanged. + +Existing messages and citation records remain untouched. A saved record without +a safe online URL shows an unavailable-link explanation instead of a fabricated +or dead link. Ask for the item again to capture current details and a Graph URL +if available. + +The model is instructed to answer directly. Source names remain appropriate when +the user asks about provenance or when distinguishing conflicting evidence. +Uncertainty, missing evidence and partial coverage must still be disclosed. +Meeting locations, organizers, numbered layouts and citation markers are kept. +No client-side phrase stripping is performed. + +## Validation + +The targeted functional suite passed **59 tests**. The browser citation suite +and the existing workflow contrast consumers passed **17 tests**, with **23** +unrelated workflow tests deselected. The V2 TypeScript/production build and the +three modified components' XSS sink checks passed. + +```powershell +python -m pytest .\functional_tests\test_m365_source_citations.py .\functional_tests\test_m365_mail_calendar_search.py -q +npm --prefix .\application\v2_ui run build +$env:PLAYWRIGHT_SERVICE_URL = '' +$env:PYTHONPATH = "$($PWD.Path)\ui_tests\fixtures" +python -m pytest .\ui_tests\test_v2_m365_source_citations.py .\ui_tests\test_v2_workflow_change_tracking.py -k "m365_source_citations or classic_edits_show_author_previous_value_revert_and_item_rows" -q +python .\scripts\check_xss_sinks.py --full-file .\application\v2_ui\src\components\chat\M365CitationChip.tsx .\application\v2_ui\src\components\chat\ConversationDrawer.tsx .\application\v2_ui\src\components\chat\MessageInspector.tsx +``` + +Production-CSS checks require at least **4.5:1** source text/action contrast. +Final email-card captures measured a minimum of **5.66:1** in light mode and +**7.54:1** in dark mode, at both **1440 x 900** and **390 x 844**. File and +calendar cards are covered by the same themed browser checks. Sidebar and Sources +actions are also measured, and intercepted Outlook pages verify the exact item +URL and new-tab behavior without accessing a real mailbox. + +The broader `test_mixed_source_manifest_contracts.py` suite has **9 existing +failures and 6 passes** in this environment. The same failures reproduce with +the unchanged `HEAD` orchestration module and live network access blocked: +legacy authorization fixtures cannot resolve some sources, and authentication +paths lack Cosmos credentials. Those unrelated fixtures and access controls are +not changed by this fix. Prompt tests validate propagation, not deterministic +wording from a live model. + +## Related documentation + +- [Microsoft 365 Source Citations]({{ '/explanation/features/M365_SOURCE_CITATIONS/' | relative_url }}) +- [Chat controls]({{ '/reference/chat-controls/' | relative_url }}) +- [Microsoft 365 Email]({{ '/reference/actions/m365-email/' | relative_url }}) +- [Microsoft 365 Calendar]({{ '/reference/actions/m365-calendar/' | relative_url }}) diff --git a/docs/reference/actions/m365-calendar.md b/docs/reference/actions/m365-calendar.md index 91afc61be..7e1345ead 100644 --- a/docs/reference/actions/m365-calendar.md +++ b/docs/reference/actions/m365-calendar.md @@ -4,7 +4,7 @@ title: "Microsoft 365 Calendar" description: "Read and search calendar events in any time range and prepare invitations using delegated Microsoft 365 access." section: "Reference" audience: user -version: "0.261.303" +version: "0.261.305" --- <!-- action-slug: m365-calendar --> @@ -54,6 +54,7 @@ with these parameters: | `order` | `oldest_first` (default) or `newest_first`, for example to find the most recent past meeting on a topic. | | `starts_in_range` | Leaves out events that were already in progress at `start_datetime`. | | `top` | Up to 25 events per call. | +| `select_fields` | Extra Graph fields to request. Event identity, subject, times, location, organizer, all-day state and `webLink` are still requested for source cards. | Dates and times without a time zone are read as UTC, and event times are returned in UTC. Since **0.261.303** each event also carries a `when_display` @@ -100,9 +101,16 @@ Implemented in version: **0.261.303** (`application/single_app/config.py`). Every event that **Read my calendar events** returns carries a citation value, and the answer cites each event it mentions with a chip that shows the event's title. Clicking the chip opens a card with when, where and the organizer, and **Open in -Outlook** opens the event in the reader's own calendar. Cited events are also -listed under **Calendar** in the conversation's **Documents** pane, with **Open -online**. +Outlook** opens the specific event in Outlook on the web in a new tab. Cited events +are also listed under **Calendar** in the conversation's **Documents** pane, with +**Open in Outlook**. The reply's **Sources** panel offers the same action. + +Refined in version: **0.261.305** (`application/single_app/config.py`). Custom +selections and keyword matching retain baseline citation metadata when temporary +scan-only fields are removed. Source cards stay readable in light and dark themes. +Saved history is unchanged: a record without an online URL explains that its link +is unavailable. Recall the event again to refresh its details and capture a Graph +link if one is available. Lists of events always use the same layout: @@ -114,6 +122,8 @@ Lists of events always use the same layout: The time span is in the reader's browser time zone, an all-day event shows "All day", and the location and organizer parts are left out when they are empty. +Direct-answer guidance avoids routine provenance introductions without removing +meeting locations, organizers, citations or incomplete-coverage disclosures. See [Microsoft 365 Source Citations]({{ '/explanation/features/M365_SOURCE_CITATIONS/' | relative_url }}). ## Workflow identity diff --git a/docs/reference/actions/m365-email.md b/docs/reference/actions/m365-email.md index bcda07d20..6c75e07a3 100644 --- a/docs/reference/actions/m365-email.md +++ b/docs/reference/actions/m365-email.md @@ -4,7 +4,7 @@ title: "Microsoft 365 Email" description: "Read and search mail and prepare or send messages without enabling unrelated Microsoft 365 tools." section: "Reference" audience: user -version: "0.261.303" +version: "0.261.305" --- <!-- action-slug: m365-email --> @@ -53,6 +53,7 @@ newest ones. The agent narrows a request with these parameters: | `folder` | `inbox` by default. Use `all` for every folder, or a folder such as `sentitems` or `archive`. | | `unread_only` | Returns only unread messages. | | `top` | Up to 25 messages per call, newest first. | +| `select_fields` | Extra Graph fields to request. Baseline source-card metadata and `webLink` are still requested, so a custom selection does not remove the subject, sender, received time or Outlook link. | Dates and times without a time zone are read as UTC. @@ -96,9 +97,16 @@ Implemented in version: **0.261.303** (`application/single_app/config.py`). Every email that **Read my mail** returns carries a citation value, and the answer cites each email it mentions with a chip that shows the subject. Clicking the chip opens a card with the sender, received time, read state, importance and a short -preview, and **Open in Outlook** opens the message in the reader's own mailbox. +preview when recorded, and **Open in Outlook** opens the specific message in +Outlook on the web in a new tab. Cited emails are also listed under **Email** in the conversation's **Documents** -pane, with **Open online**. +pane, with **Open in Outlook**. The reply's **Sources** panel offers the same action. + +Refined in version: **0.261.305** (`application/single_app/config.py`). Custom +Graph selections preserve the fields needed to build source cards, and the cards +remain readable in light and dark themes. Existing history is unchanged: if a saved +record has no online URL, the card explains that the link is unavailable and suggests +recalling the email again. Lists of emails always use the same layout, so the answer reads the same from one request to the next: @@ -116,7 +124,8 @@ question was about its content. The header says "matching" instead of "most recent" for a search. Only an `https` link from Microsoft Graph becomes a link, and stored citations -hold no message body. See +hold no message body. Answer guidance avoids routine source-provenance introductions, +but still requires citations and disclosures about uncertainty or incomplete coverage. See [Microsoft 365 Source Citations]({{ '/explanation/features/M365_SOURCE_CITATIONS/' | relative_url }}). ## Failure and approval behavior diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index d9f390e13..0e9fa290e 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.303" +version: "0.261.305" --- ## How to use this reference @@ -141,6 +141,7 @@ draft. **Do not send the retained draft again.** See ## Microsoft 365 citations and Open online Implemented in version: **0.261.303** (`application/single_app/config.py`). +Source cards and Outlook opening refined in version: **0.261.305**. When an answer uses SharePoint or OneDrive files, emails or calendar events through a Microsoft 365 action, in chat with an agent or in an orchestrated @@ -150,15 +151,19 @@ always use one numbered line per item, with times in your browser's time zone. | Control | What it does | Why you would use it | Availability | | --- | --- | --- | --- | | Microsoft 365 citation chip (V2 interface) | Shows the file name, or an email's or event's subject, with an icon for the kind of item, where the answer cites it. | See at a glance which file, message or meeting a statement came from. | Any Microsoft 365 Email, Calendar, SharePoint Online or OneDrive action | -| Source card (V2 interface) | Opens from a chip. A file shows its location, modified date and size; an email its sender, received time, read state, importance and preview; an event its time, place and organizer. An item no longer on the message says the source is no longer available. | Check the item before opening it, without leaving the conversation. | Same as the chip | +| Source card (V2 interface) | Opens from a chip in a readable light/dark dialog with contained keyboard focus. A file shows its location, modified date and size; an email its sender, received time, read state, importance and preview; an event its time, place and organizer. An item no longer on the message says the source is no longer available. | Check the item before opening it, without leaving the conversation. | Same as the chip | | Open in SharePoint, Open in OneDrive, Open in Outlook (V2 interface) | Opens the item where it lives, in a new tab, with your own sign-in. Shown only for an `https` link that Microsoft 365 supplied. | Read the whole file, message or meeting, which a citation only points to. | Same as the chip | | SharePoint & OneDrive, Email and Calendar (Documents drawer, V2 interface) | List every cited item, and every file whose content was read for an answer, with a secondary line (location and modified date, sender and received time, or time and place). Only the conversation owner sees them. | Get back to a file, message or meeting the conversation used without scrolling to the answer that cited it. | Same as the chip | -| Open online (Documents drawer, V2 interface) | Opens the listed item in SharePoint, OneDrive or Outlook in a new tab. There is no download. | Same as the Open in links, from the conversation's source list. | Same as the chip | -| Microsoft 365 in Sources (V2 interface) | Lists a reply's Microsoft 365 items in the message's **Sources** panel, with **Open online**, and marks items the reply read but did not cite. | Audit everything a reply drew on, not only what it cited. | Same as the chip | +| Open online (Documents drawer, V2 interface) | Opens a listed SharePoint or OneDrive file in a new tab. There is no download. | Return to a file from the conversation's source list. | Same as the chip | +| Open in Outlook (Documents drawer, V2 interface) | Opens the specific recalled email or calendar event in Outlook on the web, in a new tab. A missing or unsafe URL produces no link. | Read the complete message or meeting from the conversation's source list. | Same as the chip | +| Microsoft 365 in Sources (V2 interface) | Lists a reply's Microsoft 365 items in the message's **Sources** panel, with **Open online** for files and **Open in Outlook** for emails/events, and marks items the reply read but did not cite. | Audit everything a reply drew on, not only what it cited. | Same as the chip | | Microsoft 365 citation link (classic interface) | Shows the cited item's title as a link that opens it in SharePoint, OneDrive or Outlook, or as text when no link was recorded. It never opens the workspace citation viewer. | Open a cited item from the classic chat. | Same as the chip | An **Open in Outlook** link works only for the mailbox owner. File links follow -SharePoint and OneDrive permissions. See +SharePoint and OneDrive permissions. A saved record without an online URL keeps +its details; the source card explains that its link is unavailable. Recall the +item again to capture current details and a Graph URL if available. History is +not rewritten. See [Microsoft 365 Source Citations]({{ '/explanation/features/M365_SOURCE_CITATIONS/' | relative_url }}). ## Generated image editor diff --git a/functional_tests/test_m365_mail_calendar_search.py b/functional_tests/test_m365_mail_calendar_search.py index 672753602..1c07c72d5 100644 --- a/functional_tests/test_m365_mail_calendar_search.py +++ b/functional_tests/test_m365_mail_calendar_search.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional tests for searching Microsoft 365 mail and calendar history. -Version: 0.261.129 +Version: 0.261.305 Implemented in: 0.261.129 +Source-card metadata retention refined in: 0.261.305 These tests run the real Graph plugin and transport with scripted Graph responses. They check that Read my mail and Read my calendar events reach @@ -86,6 +87,7 @@ def test_mail_date_range_reaches_older_mail_and_continues_exactly(graph_plugins) ) assert calls[0]["params"]["$orderby"] == "receivedDateTime desc" assert calls[0]["params"]["$top"] == 3 + assert set(plugin.DEFAULT_MESSAGE_SELECT.split(",")) <= set(calls[0]["params"]["$select"].split(",")) assert "$search" not in calls[0]["params"] assert ids(first) == ["m1", "m2"] and first["count"] == 2 and first["truncated"] is True coverage = first["coverage"] @@ -309,6 +311,7 @@ def test_calendar_query_matches_every_word_and_hides_scan_fields(graph_plugins): assert params["startDateTime"] == "2024-01-01T00:00:00Z" assert params["endDateTime"] == "2025-01-01T00:00:00Z" assert params["$top"] == 100 + assert set(plugin.DEFAULT_EVENT_SELECT.split(",")) <= set(params["$select"].split(",")) for field in ("attendees", "bodyPreview", "categories"): assert field in params["$select"] assert ids(result) == ["review", "planning"] diff --git a/functional_tests/test_m365_source_citations.py b/functional_tests/test_m365_source_citations.py index 05288e84c..332a04b0a 100644 --- a/functional_tests/test_m365_source_citations.py +++ b/functional_tests/test_m365_source_citations.py @@ -2,8 +2,9 @@ #!/usr/bin/env python3 """ Functional test for Microsoft 365 items as first-class answer citations. -Version: 0.261.303 +Version: 0.261.305 Implemented in: 0.261.303 +Outlook metadata and direct-answer guidance refined in: 0.261.305 This test ensures that SharePoint and OneDrive files, emails and calendar events an answer uses become citation records with deterministic, underscore-free ids; that their model-facing citation @@ -38,6 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) import functions_m365_citations as citations # noqa: E402 +import functions_mixed_source_orchestration as mixed_source # noqa: E402 from functions_citation_tracking import extract_explicit_document_citation_ids, rebuild_conversation_used_documents # noqa: E402 from functions_m365_transport import M365CloudConfig, M365Transport # noqa: E402 from test_support.versioning import assert_app_version_at_least # noqa: E402 @@ -48,6 +50,7 @@ IMPLEMENTED_IN = "0.261.303" +REFINED_IN = "0.261.305" SHARED_MARKER_PATTERN = ( r"\(Source:\s*((?:(?!\(Source:).)+?),\s*(Page(?:s)?|Sheet(?:s)?|Location):\s*" r"((?:(?!\(Source:).)+?)\)\s*((?:\[#.*?\]\s*)+)" @@ -99,6 +102,7 @@ def _file_identity(item_id="01ITEM", name="20170010188.pdf"): def test_version_is_at_least_the_implementing_release(): assert_app_version_at_least(IMPLEMENTED_IN) + assert_app_version_at_least(REFINED_IN) def test_citation_ids_are_deterministic_and_never_contain_an_underscore(): @@ -321,17 +325,113 @@ def _load_private_plugin_logger(monkeypatch): return module -def _scripted_graph_plugin(modules, source, pages): +def _scripted_graph_plugin(modules, source, pages, calls=None): + selected_fields = set() + + def request(method, url, **kwargs): + nonlocal selected_fields + params = dict(kwargs.get("params") or {}) + if calls is not None: + calls.append({"url": url, "params": params}) + if "$select" in params: + selected_fields = {field.strip().lower() for field in params["$select"].split(",")} + payload = dict(pages.pop(0)) + if isinstance(payload.get("value"), list): + payload["value"] = [ + {key: value for key, value in item.items() if key.lower() in selected_fields} + for item in payload["value"] + ] + return FakeResponse(payload) + plugin = modules["msgraph_plugin"].MSGraphPlugin({"id": "legacy"}) plugin._transports[source] = M365Transport( source, "legacy", cloud=M365CloudConfig("https://graph.microsoft.com/v1.0", "https://login.microsoftonline.com/tenant"), - request=Mock(side_effect=lambda method, url, **kwargs: FakeResponse(pages.pop(0))), + request=Mock(side_effect=request), token_provider=Mock(return_value={"access_token": "unit-test-token"}), ) return plugin +@pytest.mark.parametrize("source,selection,matching", [ + ("email", "", False), + ("email", "subject,bodyPreview", False), + ("email", "subject", True), + ("calendar", "", False), + ("calendar", "subject,bodyPreview", False), + ("calendar", "subject", True), +]) +def test_graph_selections_retain_outlook_links_and_source_card_metadata(graph_plugins, source, selection, matching): + modules, _ = graph_plugins + calls = [] + if source == "email": + item = _message("AAMkSelected", "Project review", "2026-10-07T16:52:00Z", bodyPreview="Review the plan.") + else: + item = _event( + "selected-occurrence", "Project review", "2026-10-08T18:00:00", "2026-10-08T18:30:00", + bodyPreview="Review the plan.", attendees=[], categories=[], + ) + plugin = _scripted_graph_plugin(modules, source, [{"value": [item]}], calls=calls) + + with citations.m365_display_time_zone("America/New_York"): + if source == "email": + result = plugin.get_my_messages( + select_fields=selection, search="review" if matching else "", + received_from="2026-10-01", received_to="2026-10-31", + ) + required = plugin.DEFAULT_MESSAGE_SELECT.split(",") + else: + result = plugin.get_my_events( + select_fields=selection, query="review" if matching else "", + start_datetime="2026-10-08", end_datetime="2026-10-09", + ) + required = plugin.DEFAULT_EVENT_SELECT.split(",") + records = citations.captured_m365_records(result) + + assert set(required) <= set(calls[0]["params"]["$select"].split(",")) + assert len(records) == 1 + record = records[0] + assert record["web_url"] == item["webLink"] + assert record["title"] == "Project review" + assert result["value"][0]["citation_id"] == record["citation_id"] + assert result["coverage"]["complete"] is True + if source == "email": + assert record["from_name"] == "Microsoft Security" + assert record["received_display"] == "Oct 7, 2026, 12:52 PM EDT" + assert record["is_read"] is False + else: + assert record["when_display"] == "Thu, Oct 8, 2026, 2:00 PM \u2013 2:30 PM EDT" + assert record["organizer_name"] == "Ann Lee" + assert record["location"] == "Teams" + assert "attendees" not in result["value"][0] and "categories" not in result["value"][0] + assert ("bodyPreview" in result["value"][0]) == ("bodyPreview" in selection) + + +def test_custom_mail_selection_keeps_outlook_links_across_pagination_and_continuation(graph_plugins): + modules, _ = graph_plugins + calls = [] + newest = _message("AAMkNewest", "Newest", "2026-10-07T16:52:00Z") + older = _message("AAMkOlder", "Older", "2026-10-07T12:00:00Z", webLink=OUTLOOK_LINK + "&page=older") + plugin = _scripted_graph_plugin(modules, "email", [ + {"value": [newest], "@odata.nextLink": "https://graph.microsoft.com/v1.0/me/messages?page=2"}, + {"value": [older]}, + {"value": [older]}, + ], calls=calls) + + first = plugin.get_my_messages(top=1, folder="all", select_fields="subject") + second = plugin.get_my_messages(**first["coverage"]["continue_with"]) + first_records = citations.captured_m365_records(first) + second_records = citations.captured_m365_records(second) + + assert calls[1]["params"] == {} + assert first["coverage"]["continue_with"]["select_fields"] == "subject" + assert "webLink" in calls[0]["params"]["$select"].split(",") + assert "webLink" in calls[2]["params"]["$select"].split(",") + assert [record["web_url"] for record in first_records] == [newest["webLink"]] + assert [record["web_url"] for record in second_records] == [older["webLink"]] + assert first["coverage"]["complete"] is False and second["coverage"]["complete"] is True + + def test_real_graph_plugin_returns_cited_mail_and_events_in_the_readers_zone(graph_plugins): modules, _ = graph_plugins plugin = _scripted_graph_plugin(modules, "email", [{"value": [ @@ -683,15 +783,53 @@ def test_workflow_answers_record_and_mirror_their_m365_citations(): assert "_merge_workflow_m365_items(conversation_doc, mirrored_assistant_doc)" in mirror -def test_compose_and_handoff_prompts_require_verbatim_citations(): +def test_m365_answer_guidance_is_direct_without_hiding_provenance_or_limitations(): + guidance = citations.M365_ANSWER_STYLE_INSTRUCTIONS + records = [ + citations.normalize_m365_email(_message("message-1", "Budget", "2026-10-07T16:00:00Z")), + citations.normalize_m365_event(_event("event-1", "Standup", "2026-10-08T18:00:00", "2026-10-08T18:30:00")), + citations.normalize_m365_file(_file_identity(), "spo"), + ] + note = citations.build_m365_sources_note(records) + empty_note = citations.build_m365_sources_note([]) + markers = [citations.build_m365_citation_marker(record) for record in records] + assert guidance.startswith("For Microsoft 365 items,") + for requirement in ( + "answer the user's question directly", "without routine source-provenance", + "when the user asks about provenance", "conflicting evidence", + "Preserve uncertainty, missing evidence and partial-coverage disclosures", + ): + assert requirement in guidance + assert guidance in citations.M365_CITATION_INSTRUCTIONS + assert 'copying its "citation" value verbatim' in citations.M365_CITATION_INSTRUCTIONS + assert guidance in note + assert empty_note == "" + for marker in markers: + assert marker in note + assert "organizer Ann Lee" in note and "Teams" in note + + +def test_compose_and_handoff_prompts_require_verbatim_citations_and_direct_answers(): composition = (APP_DIR / "functions_orchestration_composition.py").read_text(encoding="utf-8") mixed = (APP_DIR / "functions_mixed_source_orchestration.py").read_text(encoding="utf-8") + policy = next( + node for node in ast.parse(composition).body + if isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "CITATION_POLICY" for target in node.targets) + ) + namespace = {"M365_ANSWER_STYLE_INSTRUCTIONS": citations.M365_ANSWER_STYLE_INSTRUCTIONS} + exec(compile(ast.Module(body=[policy], type_ignores=[]), "composition_citation_policy", "exec"), namespace) + handoff = mixed_source.build_mixed_source_evidence_handoff([], [], mixed_source.SELECTION_MODE_RELEVANCE) + marker = citations.build_m365_citation_marker(citations.normalize_m365_email(_message("a", "b", "2026-10-07T16:00:00Z"))) + pattern = re.compile(r"\(Source:.{1,400}?\)\s*\[#") + assert "from functions_m365_citations import M365_ANSWER_STYLE_INSTRUCTIONS" in composition + assert citations.M365_ANSWER_STYLE_INSTRUCTIONS in namespace["CITATION_POLICY"] + assert citations.M365_ANSWER_STYLE_INSTRUCTIONS in handoff["content"] + assert handoff["role"] == "system" and handoff["mixed_source_coverage"]["partial_coverage"] is False assert "CITATION_POLICY if _carries_citation_values(inputs) else ''" in composition assert "copy its citation value verbatim" in composition assert "presentation field" in composition assert "verbatim right after the claim it supports" in mixed - pattern = re.compile(r"\(Source:.{1,400}?\)\s*\[#") - marker = citations.build_m365_citation_marker(citations.normalize_m365_email(_message("a", "b", "2026-10-07T16:00:00Z"))) assert pattern.search(json.dumps({"notes": [marker]}, ensure_ascii=False)) assert not pattern.search(json.dumps({"notes": ["No sources here."]})) diff --git a/ui_tests/fixtures/color_contrast.py b/ui_tests/fixtures/color_contrast.py new file mode 100644 index 000000000..75b2014c1 --- /dev/null +++ b/ui_tests/fixtures/color_contrast.py @@ -0,0 +1,45 @@ +# color_contrast.py +"""Browser contrast measurement against the actual composited ancestor surfaces. + +Version: 0.261.305 +Implemented in: 0.261.305 +""" + +CONTRAST_RATIO_SCRIPT = """ +(element, property) => { + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 1; + const context = canvas.getContext('2d', {willReadFrequently: true}); + const rgba = (color) => { + context.clearRect(0, 0, 1, 1); + context.fillStyle = color; + context.fillRect(0, 0, 1, 1); + const pixel = context.getImageData(0, 0, 1, 1).data; + return [pixel[0], pixel[1], pixel[2], pixel[3] / 255]; + }; + const over = (top, bottom) => { + const alpha = top[3] + bottom[3] * (1 - top[3]); + return [0, 1, 2].map((index) => + (top[index] * top[3] + bottom[index] * bottom[3] * (1 - top[3])) / alpha + ).concat(alpha); + }; + let background = [0, 0, 0, 0]; + for (let parent = element; parent; parent = parent.parentElement) { + const color = rgba(getComputedStyle(parent).backgroundColor); + if (color[3]) { + background = over(background, color); + } + if (background[3] === 1) break; + } + if (background[3] !== 1) throw new Error('Contrast requires an opaque ancestor'); + const foreground = over(rgba(getComputedStyle(element)[property]), background); + const luminance = (color) => color.slice(0, 3).reduce((sum, value, index) => { + const channel = value / 255; + const linear = channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + return sum + linear * [0.2126, 0.7152, 0.0722][index]; + }, 0); + const light = Math.max(luminance(background), luminance(foreground)); + const dark = Math.min(luminance(background), luminance(foreground)); + return (light + 0.05) / (dark + 0.05); +} +""" diff --git a/ui_tests/test_v2_m365_source_citations.py b/ui_tests/test_v2_m365_source_citations.py index 3e79a8614..5e70a9553 100644 --- a/ui_tests/test_v2_m365_source_citations.py +++ b/ui_tests/test_v2_m365_source_citations.py @@ -1,16 +1,20 @@ # test_v2_m365_source_citations.py """ UI test for Microsoft 365 items as first-class citations in the V2 and classic chat. -Version: 0.261.303 +Version: 0.261.305 Implemented in: 0.261.303 +Source-card contrast, focus and Outlook opening refined in: 0.261.305 This test ensures that an answer citing SharePoint or OneDrive files, emails and calendar events renders compact citation chips in V2 that open a source card with the item's details and an "Open in SharePoint / OneDrive / Outlook" link with safe attributes, never calling the workspace citation endpoint; that a record the message no longer has shows a friendly notice; that the V2 chat request carries the browser time zone; that the Documents pane lists the items under -SharePoint & OneDrive, Email and Calendar with "Open online" links, dropping any non-https link; -and that the classic chat links each item to where it lives without fetching a passage. +SharePoint & OneDrive, Email and Calendar with "Open online" file links and "Open in Outlook" +mail/event links, dropping any non-https link; and that the classic chat links each item to +where it lives without fetching a passage. Production CSS checks cover light/dark contrast, +desktop/phone layouts, keyboard containment/restoration, unavailable historic links and +literal metadata. Outlook actions open the exact recalled item in a new tab. It drives the real bundled V2 components and the real classic modules over a local static server with synthetic HTTP boundaries, so no Azure credentials or network access are needed. @@ -22,6 +26,7 @@ import pytest from playwright.sync_api import expect +from ui_tests.fixtures.color_contrast import CONTRAST_RATIO_SCRIPT from ui_tests.fixtures.playwright_connection import connect_options # noqa: F401 from ui_tests.fixtures.orchestration import harness_build @@ -33,6 +38,7 @@ pytestmark = pytest.mark.ui IMPLEMENTED_IN = "0.261.303" +REFINED_IN = "0.261.305" CONVERSATION_ID = "conversation-m365" OUTLOOK_LINK = "https://outlook.office365.com/owa/?ItemID=AAMkAD%2Bx&exvsurl=1&viewmodel=ReadMessageItem" EVENT_LINK = "https://outlook.office365.com/owa/?itemid=AAMkAE&exvsurl=1&path=/calendar/item" @@ -83,6 +89,63 @@ def _open_v2_harness(page, origin): 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)") + styles = sorted((ROOT / "application" / "single_app" / "static" / "v2" / "assets").glob("*.css")) + assert styles, "Build the V2 UI before checking citation styles." + for stylesheet in styles: + page.add_style_tag(content=stylesheet.read_text(encoding="utf-8")) + + +def _mount_saved_sources(page, records, with_drawer=False): + page.evaluate( + """({answer, records, withDrawer}) => { + window.m365HarnessRequests = []; + window.m365Injected = false; + window.fetch = async (url) => { + window.m365HarnessRequests.push(String(url)); + return new Response(JSON.stringify({ success: true, messages: [] }), { + headers: { 'Content-Type': 'application/json' }, + }); + }; + 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: [{ + id: 'reply-m365', role: 'assistant', content: answer, augmented: true, + m365_citations: records, + }], + streaming: false, loadingMessages: false, drawerMode: withDrawer ? 'documents' : null, + metadataLoading: false, metadataError: null, + metadata: { + conversation_id: 'conversation-m365', title: 'Microsoft 365 recall', + used_documents_tracking_version: 1, used_documents: [], used_m365_items: records, + }, + }); + H.mount('mount-a', 'MessageList'); + if (withDrawer) H.mount('mount-b', 'ConversationDrawer'); + }""", + {"answer": ANSWER, "records": records, "withDrawer": with_drawer}, + ) + + +def _assert_dialog_fits(card, viewport): + panel = card.locator(".glass-modal") + box = panel.bounding_box() + assert box is not None + assert box["x"] >= 0 and box["y"] >= 0 + assert box["x"] + box["width"] <= viewport["width"] + 1 + assert box["y"] + box["height"] <= viewport["height"] + 1 + fits = panel.evaluate("element => element.scrollWidth <= element.clientWidth + 1") + assert fits, "Source details overflow the dialog horizontally." + + +def _assert_no_workspace_citation_fetch(page): + requests = page.evaluate("() => window.m365HarnessRequests") + assert not any("/api/get_citation" in url for url in requests) def _citation_requests(page): @@ -99,6 +162,7 @@ def _expect_safe_link(link, href): def test_version_is_at_least_the_implementing_release(): assert_app_version_at_least(IMPLEMENTED_IN) + assert_app_version_at_least(REFINED_IN) def test_streamed_answer_chips_open_source_cards_with_safe_links(page): @@ -201,7 +265,7 @@ def test_streamed_answer_chips_open_source_cards_with_safe_links(page): assert errors == [] -def test_documents_pane_lists_m365_items_with_open_online_links(page): +def test_documents_pane_lists_m365_items_with_source_specific_links(page): try: harness_build.ensure_bundle() except harness_build.HarnessUnavailable as exc: @@ -252,18 +316,161 @@ def test_documents_pane_lists_m365_items_with_open_online_links(page): email = drawer.get_by_role("region", name="Email") expect(email).to_contain_text("Microsoft Security \u00b7 Oct 7, 2026, 12:52 PM EDT") expect(email).to_contain_text("Cited") - _expect_safe_link(email.get_by_role("link", name="Open online: PIM: Role activated"), OUTLOOK_LINK) + _expect_safe_link(email.get_by_role("link", name="Open in Outlook: PIM: Role activated"), OUTLOOK_LINK) calendar = drawer.get_by_role("region", name="Calendar") expect(calendar).to_contain_text("Thu, Oct 8, 2026, 2:00 PM \u2013 2:30 PM EDT \u00b7 Teams") - _expect_safe_link(calendar.get_by_role("link", name="Open online: Standup"), EVENT_LINK) + _expect_safe_link(calendar.get_by_role("link", name="Open in Outlook: Standup"), EVENT_LINK) # Narrow viewport: each row's link stays reachable in the drawer. page.set_viewport_size({"width": 390, "height": 844}) - expect(email.get_by_role("link", name="Open online: PIM: Role activated")).to_be_in_viewport() + expect(email.get_by_role("link", name="Open in Outlook: PIM: Role activated")).to_be_in_viewport() + assert errors == [] + + +@pytest.mark.parametrize("theme", ["light", "dark"]) +@pytest.mark.parametrize("viewport", [{"width": 1440, "height": 900}, {"width": 390, "height": 844}]) +def test_source_dialog_is_readable_and_keyboard_contained(page, theme, viewport): + harness_build.ensure_bundle() + page.set_viewport_size(viewport) + page.emulate_media(reduced_motion="reduce") + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + with harness_build.start_static_server() as origin: + _open_v2_harness(page, origin) + page.evaluate("theme => document.documentElement.classList.toggle('dark', theme === 'dark')", theme) + _mount_saved_sources(page, [EMAIL, EVENT, FILE]) + for record, label in [(EMAIL, "Open in Outlook"), (EVENT, "Open in Outlook"), (FILE, "Open in SharePoint")]: + chip = page.locator(f"#mount-a button[data-m365-citation-id='{record['citation_id']}']") + chip.click() + card = page.get_by_role("dialog", name="Microsoft 365 source", exact=True) + expect(card).to_be_visible() + action = card.get_by_role("link", name=f"{label}: {record['title']}", exact=True) + _expect_safe_link(action, record["web_url"]) + for element in [ + action, card.get_by_role("heading", name=record["title"], exact=True), + card.locator("dt").first, card.locator("dd").first, card.locator("p").first, + ]: + ratio = element.evaluate(CONTRAST_RATIO_SCRIPT, "color") + assert ratio >= 4.5, f"{theme} source text contrast {ratio:.2f} is below 4.5:1." + action.hover() + hover_ratio = action.evaluate(CONTRAST_RATIO_SCRIPT, "color") + assert hover_ratio >= 4.5 + outside_markdown = card.evaluate("element => !element.closest('#mount-a')") + assert outside_markdown, "The source dialog must escape the answer's Markdown styles." + _assert_dialog_fits(card, viewport) + expect(action).to_be_in_viewport() + close = card.get_by_role("button", name="Close source", exact=True) + expect(close).to_be_focused() + page.keyboard.press("Shift+Tab") + expect(action).to_be_focused() + page.keyboard.press("Tab") + expect(close).to_be_focused() + page.keyboard.press("Escape") + expect(card).to_have_count(0) + expect(chip).to_be_focused() + _assert_no_workspace_citation_fetch(page) + assert errors == [] + + +@pytest.mark.parametrize("record", [EMAIL, EVENT], ids=["email", "calendar"]) +@pytest.mark.parametrize("theme", ["light", "dark"]) +def test_outlook_links_open_specific_items_from_sidebar_popup_and_sources_in_new_tabs(page, record, theme): + harness_build.ensure_bundle() + errors = [] + page.on("pageerror", lambda error: errors.append(str(error))) + + def serve_outlook(route): + route.fulfill(content_type="text/html", body="<h1>Synthetic Outlook item</h1>") + + page.context.route("https://outlook.office365.com/**", serve_outlook) + try: + with harness_build.start_static_server() as origin: + _open_v2_harness(page, origin) + page.evaluate("theme => document.documentElement.classList.toggle('dark', theme === 'dark')", theme) + _mount_saved_sources(page, [EMAIL, EVENT, FILE], with_drawer=True) + label = f"Open in Outlook: {record['title']}" + sidebar_action = page.locator("#mount-b").get_by_role("link", name=label, exact=True) + _expect_safe_link(sidebar_action, record["web_url"]) + sidebar_ratio = sidebar_action.evaluate(CONTRAST_RATIO_SCRIPT, "color") + assert sidebar_ratio >= 4.5 + with page.expect_popup() as opened: + sidebar_action.click() + outlook = opened.value + expect(outlook.get_by_role("heading", name="Synthetic Outlook item")).to_be_visible() + assert outlook.url == record["web_url"] + outlook.close() + + page.locator(f"#mount-a button[data-m365-citation-id='{record['citation_id']}']").click() + card = page.get_by_role("dialog", name="Microsoft 365 source", exact=True) + popup_action = card.get_by_role("link", name=label, exact=True) + _expect_safe_link(popup_action, record["web_url"]) + with page.expect_popup() as opened: + popup_action.click() + outlook = opened.value + expect(outlook.get_by_role("heading", name="Synthetic Outlook item")).to_be_visible() + assert outlook.url == record["web_url"] + outlook.close() + card.get_by_role("button", name="Close source").click() + + page.get_by_role("button", name="Show sources (3)", exact=True).click() + sources = page.locator("#mount-a") + source_action = sources.get_by_role("link", name=label, exact=True) + _expect_safe_link(source_action, record["web_url"]) + _expect_safe_link(sources.get_by_role("link", name="Open online: 20170010188.pdf"), SPO_LINK) + source_ratio = source_action.evaluate(CONTRAST_RATIO_SCRIPT, "color") + assert source_ratio >= 4.5 + with page.expect_popup() as opened: + source_action.click() + outlook = opened.value + expect(outlook.get_by_role("heading", name="Synthetic Outlook item")).to_be_visible() + assert outlook.url == record["web_url"] + outlook.close() + _assert_no_workspace_citation_fetch(page) + finally: + page.context.unroute("https://outlook.office365.com/**", serve_outlook) assert errors == [] +@pytest.mark.parametrize( + "web_url", + [None, "javascript:alert(1)", "https://username:secret@outlook.office365.com/owa/"], + ids=["historic-missing-link", "unsafe-scheme", "embedded-credentials"], +) +def test_source_card_wraps_literal_metadata_and_explains_unavailable_links(page, web_url): + harness_build.ensure_bundle() + viewport = {"width": 390, "height": 844} + page.set_viewport_size(viewport) + record = { + **EMAIL, + "title": "Budget <img src=x onerror=window.m365Injected=true>", + "from_address": f"{'a' * 220}@contoso.com", + "preview": "Source text " * 60, + "web_url": web_url, + } + with harness_build.start_static_server() as origin: + _open_v2_harness(page, origin) + _mount_saved_sources(page, [record]) + chip = page.locator(f"#mount-a button[data-m365-citation-id='{EMAIL_ID}']") + chip.click() + card = page.get_by_role("dialog", name="Microsoft 365 source", exact=True) + expect(card.get_by_role("heading", name=record["title"], exact=True)).to_be_visible() + expect(card.get_by_role("link")).to_have_count(0) + expect(card.get_by_text("No online link is available for this source.", exact=False)).to_be_in_viewport() + expect(card.locator("img, script")).to_have_count(0) + expect(card).not_to_contain_text("secret") + _assert_dialog_fits(card, viewport) + injected = page.evaluate("() => window.m365Injected") + assert injected is False + close = card.get_by_role("button", name="Close source", exact=True) + page.keyboard.press("Tab") + expect(close).to_be_focused() + page.keyboard.press("Escape") + expect(card).to_have_count(0) + expect(chip).to_be_focused() + _assert_no_workspace_citation_fetch(page) + + def test_classic_chat_links_m365_items_and_never_fetches_passages(page): citation_requests = _citation_requests(page) with harness_build.start_static_server() as origin: diff --git a/ui_tests/test_v2_workflow_change_tracking.py b/ui_tests/test_v2_workflow_change_tracking.py index 3bf02775d..f4ee57ffd 100644 --- a/ui_tests/test_v2_workflow_change_tracking.py +++ b/ui_tests/test_v2_workflow_change_tracking.py @@ -1,9 +1,10 @@ # test_v2_workflow_change_tracking.py """ Offline real-bundle browser regressions for change tracking in the V2 workflow editor. -Version: 0.261.271 +Version: 0.261.305 The Workflows workbench and the routed editor page replace the list rows and the editor dialog: 0.261.271 Implemented in: 0.261.203 +Shared contrast measurement extracted in: 0.261.305 Covers the highlight on each unsaved change (its author badge, Previously value and Revert), Removed · Restore rows on the List and Flow surfaces, and the side panel's Changes tab with @@ -34,6 +35,7 @@ # The shared fixtures import pure application helpers after setting their paths. from ui_tests import test_v2_workflow_flow_authoring as authoring +from ui_tests.fixtures.color_contrast import CONTRAST_RATIO_SCRIPT as CONTRAST from ui_tests.fixtures.workflow_editor import OWNER_ID, WORKFLOW_ID from ui_tests.fixtures.workflow_flow import GROUP_ID, FLOW_WORKFLOW_ID, MALICIOUS_LABEL from ui_tests.test_v2_workflow_flow_authoring import authoring_ui, connect_options # noqa: F401 @@ -62,46 +64,6 @@ UNSUPPORTED_SCHEDULE = {"kind": "cron", "expression": "0 8 * * 1", "timezone": "America/New_York"} UNSUPPORTED_SCHEDULE_NOTE = "This workflow's schedule can't be shown or changed in this editor." -# Resolves alpha against the actual ancestor surfaces, as the admin visual hierarchy test does. -CONTRAST = """ -(element, property) => { - const canvas = document.createElement('canvas'); - canvas.width = canvas.height = 1; - const context = canvas.getContext('2d', {willReadFrequently: true}); - const rgba = (color) => { - context.clearRect(0, 0, 1, 1); - context.fillStyle = color; - context.fillRect(0, 0, 1, 1); - const pixel = context.getImageData(0, 0, 1, 1).data; - return [pixel[0], pixel[1], pixel[2], pixel[3] / 255]; - }; - const over = (top, bottom) => { - const alpha = top[3] + bottom[3] * (1 - top[3]); - return [0, 1, 2].map((index) => - (top[index] * top[3] + bottom[index] * bottom[3] * (1 - top[3])) / alpha - ).concat(alpha); - }; - let background = [0, 0, 0, 0]; - for (let parent = element; parent; parent = parent.parentElement) { - const color = rgba(getComputedStyle(parent).backgroundColor); - if (color[3]) { - background = over(background, color); - } - if (background[3] === 1) break; - } - if (background[3] !== 1) throw new Error('Contrast requires an opaque ancestor'); - const foreground = over(rgba(getComputedStyle(element)[property]), background); - const luminance = (color) => color.slice(0, 3).reduce((sum, value, index) => { - const channel = value / 255; - const linear = channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; - return sum + linear * [0.2126, 0.7152, 0.0722][index]; - }, 0); - const light = Math.max(luminance(background), luminance(foreground)); - const dark = Math.min(luminance(background), luminance(foreground)); - return (light + 0.05) / (dark + 0.05); -} -""" - # A visible label that wraps inside its button has more than one line box. SINGLE_LINE_BUTTONS = """ footer => [...footer.querySelectorAll('button')].filter((button) => button.getClientRects().length).every((button) => {