From bf714a88076c08042b91e21f6afdfed7864e8228 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 3 Oct 2026 15:20:01 -0400 Subject: [PATCH] Show a workflow's mirrored reply once in the conversation it created Hide the run's own post once its full reply is mirrored into the created conversation, and hide the workflow conversation from the chat list after the first delivery. --- application/single_app/config.py | 2 +- .../single_app/functions_workflow_runner.py | 154 ++++++ .../static/js/chat/chat-collaboration.js | 6 + .../static/js/chat/chat-messages.js | 4 + .../v2_ui/src/components/chat/MessageList.tsx | 13 +- application/v2_ui/src/lib/sharedMessage.ts | 15 + .../V2_INLINE_MEDIA_AND_AGENT_MESSAGES.md | 4 + ...REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md | 91 ++++ ...W_VISUALIZATION_CONVERSATION_MIRROR_FIX.md | 5 +- docs/explanation/release_notes.md | 10 + docs/guides/create-a-workflow.md | 21 + ...low_reply_replaces_agent_posted_message.py | 479 ++++++++++++++++++ ui_tests/test_chat_workflow_results.py | 53 +- 13 files changed, 848 insertions(+), 9 deletions(-) create mode 100644 docs/explanation/fixes/WORKFLOW_REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md create mode 100644 functional_tests/test_workflow_reply_replaces_agent_posted_message.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 17903e612..fe62d3837 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.252" +VERSION = "0.261.253" 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_workflow_runner.py b/application/single_app/functions_workflow_runner.py index 6b93ddf57..204239f28 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -60,6 +60,7 @@ TABULAR_EXTENSIONS, VERSION, cognitive_services_scope, + cosmos_collaboration_messages_container, cosmos_conversations_container, cosmos_group_documents_container, cosmos_messages_container, @@ -105,6 +106,7 @@ get_collaboration_conversation, mirror_source_message_to_collaboration, ) +from functions_conversation_cache import bump_conversation_cache_version from functions_generated_file_exports import ( normalize_complete_xml_artifact_payload, normalize_xml_artifact_payload, @@ -4763,6 +4765,139 @@ def _filter_visualization_agent_citations(agent_citations): return [citation for citation in agent_citations or [] if _is_visualization_citation(citation)] +# Set on a message a run posted itself once the run's reply has been mirrored into the same +# conversation. The message stays stored, and in the conversation's AI history, but clients +# do not show it: the reply carries the same findings with the maps and sources the tools +# returned, and the posted copy reads as though the person had written it. +WORKFLOW_REPLY_SUPERSEDED_METADATA_KEY = 'superseded_by_workflow_reply' + + +def _extract_run_posted_message_ids_from_citations(agent_citations): + """Return the ids of the messages a run posted itself, keyed by conversation id. + + Covers the opening message a create call seeded and every add_conversation_message call. + The ids come from the tool results, so only messages this run posted can match. + """ + seeding_function_names = { + 'create_group_conversation', + 'create_personal_collaboration_conversation', + 'create_personal_conversation', + } + posted_message_ids = {} + + for citation in agent_citations or []: + if not isinstance(citation, dict) or citation.get('plugin_name') != 'SimpleChatPlugin': + continue + function_name = citation.get('function_name') + if function_name not in seeding_function_names and function_name != 'add_conversation_message': + continue + if citation.get('success') is False: + continue + + invocation_result = citation.get('function_result') if isinstance(citation.get('function_result'), dict) else {} + if invocation_result.get('success') is False: + continue + if function_name in seeding_function_names and invocation_result.get('seeded_initial_message') is not True: + continue + + message_doc = invocation_result.get('message') if isinstance(invocation_result.get('message'), dict) else {} + conversation_doc = invocation_result.get('conversation') if isinstance(invocation_result.get('conversation'), dict) else {} + message_id = str(message_doc.get('id') or '').strip() + conversation_id = str(conversation_doc.get('id') or message_doc.get('conversation_id') or '').strip() + if not message_id or not conversation_id: + continue + + conversation_message_ids = posted_message_ids.setdefault(conversation_id, []) + if message_id not in conversation_message_ids: + conversation_message_ids.append(message_id) + + return posted_message_ids + + +def _hide_run_posts_superseded_by_reply(workflow, conversation_id, message_ids, reply_message_doc, collaboration): + """Withdraw the run's own posts from view once its reply is mirrored into the conversation. + + Only messages posted through the agent action are marked (posted_via 'agent_action', set by + add_conversation_message_for_current_user). Best effort: a failure leaves the post visible + and never undoes the mirror. + """ + reply_message_id = str((reply_message_doc or {}).get('id') or '').strip() + if not conversation_id or not reply_message_id or not message_ids: + return [] + + container = cosmos_collaboration_messages_container if collaboration else cosmos_messages_container + marker = { + 'message_id': reply_message_id, + 'workflow_id': str(workflow.get('id') or '').strip(), + 'superseded_at': _utc_now_iso(), + } + hidden_message_ids = [] + for message_id in message_ids: + if message_id == reply_message_id: + continue + try: + message_doc = container.read_item(item=message_id, partition_key=conversation_id) + metadata = message_doc.get('metadata') if isinstance(message_doc.get('metadata'), dict) else {} + if metadata.get('posted_via') != 'agent_action': + continue + if not metadata.get(WORKFLOW_REPLY_SUPERSEDED_METADATA_KEY): + message_doc['metadata'] = {**metadata, WORKFLOW_REPLY_SUPERSEDED_METADATA_KEY: marker} + container.upsert_item(message_doc) + hidden_message_ids.append(message_id) + except CosmosResourceNotFoundError: + continue + except Exception as exc: + log_event( + f'[WORKFLOW_RUNNER] Failed to hide a run-posted message replaced by the mirrored reply: {exc}', + extra={ + 'workflow_id': str(workflow.get('id') or '').strip(), + 'conversation_id': conversation_id, + 'message_id': message_id, + }, + level=logging.WARNING, + exceptionTraceback=True, + ) + return hidden_message_ids + + +def _hide_workflow_conversation_after_delivery(conversation_id): + """Hide a workflow's own conversation the first time a run delivers into one it created. + + The created conversation is where people read the result; the workflow conversation is the + run's working record and stays reachable by link. The timestamp makes this a one-time + default, so a person who shows the conversation again is not overruled by later runs. + """ + if not conversation_id: + return False + try: + conversation_doc = cosmos_conversations_container.read_item( + item=conversation_id, + partition_key=conversation_id, + ) + if str(conversation_doc.get('chat_type') or '').strip() != 'workflow': + return False + if conversation_doc.get('is_hidden') or conversation_doc.get('workflow_delivery_hidden_at'): + return False + conversation_doc['is_hidden'] = True + conversation_doc['workflow_delivery_hidden_at'] = _utc_now_iso() + cosmos_conversations_container.upsert_item(conversation_doc) + bump_conversation_cache_version( + str(conversation_doc.get('user_id') or '').strip(), + reason='workflow_conversation_hidden_after_delivery', + ) + return True + except CosmosResourceNotFoundError: + return False + except Exception as exc: + log_event( + f'[WORKFLOW_RUNNER] Failed to hide the workflow conversation after delivery: {exc}', + extra={'conversation_id': conversation_id}, + level=logging.WARNING, + exceptionTraceback=True, + ) + return False + + def _is_collaboration_target_conversation(conversation_doc): chat_type = str((conversation_doc or {}).get('chat_type') or '').strip() conversation_kind = str((conversation_doc or {}).get('conversation_kind') or '').strip() @@ -4905,6 +5040,7 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as 'hybrid_citations': hybrid_citations, 'web_search_citations': web_search_citations, } + run_posted_message_ids = _extract_run_posted_message_ids_from_citations(raw_agent_citations) mirrored_message_ids = [] for created_conversation in created_conversations: @@ -4928,6 +5064,14 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as if created and mirrored_message_doc: create_collaboration_message_notifications(updated_conversation, mirrored_message_doc) mirrored_message_ids.append(mirrored_message_doc.get('id')) + if mirrored_message_doc: + _hide_run_posts_superseded_by_reply( + workflow, + conversation_id, + run_posted_message_ids.get(conversation_id), + mirrored_message_doc, + collaboration=True, + ) else: mirrored_message_doc = _mirror_assistant_message_to_personal_conversation( workflow, @@ -4937,6 +5081,13 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as ) if mirrored_message_doc: mirrored_message_ids.append(mirrored_message_doc.get('id')) + _hide_run_posts_superseded_by_reply( + workflow, + conversation_id, + run_posted_message_ids.get(conversation_id), + mirrored_message_doc, + collaboration=False, + ) except Exception as exc: log_event( f'[WORKFLOW_RUNNER] Failed to mirror workflow visualizations into conversation {conversation_id}: {exc}', @@ -4949,6 +5100,9 @@ def _mirror_workflow_visualizations_to_created_conversations(workflow, source_as exceptionTraceback=True, ) + if mirrored_message_ids: + _hide_workflow_conversation_after_delivery(source_conversation_id) + return mirrored_message_ids diff --git a/application/single_app/static/js/chat/chat-collaboration.js b/application/single_app/static/js/chat/chat-collaboration.js index 7d8b53dd7..0c71f3a8d 100644 --- a/application/single_app/static/js/chat/chat-collaboration.js +++ b/application/single_app/static/js/chat/chat-collaboration.js @@ -1015,6 +1015,12 @@ async function loadConversationMessages(conversationId) { .map(message => String(message.id)) ); messages.forEach(message => { + // A workflow run's own post is replaced by its mirrored reply, which says the same. + // It stays cached so a reply that quotes it still resolves. + if (message?.metadata?.superseded_by_workflow_reply) { + cacheCollaborationMessage(message); + return; + } const decoratedMessage = decorateReplyMessage(message); if (decoratedMessage.role === 'assistant' && generatedImageProposalMessages.has(decoratedMessage.id)) { decoratedMessage.generated_image_proposals = generatedImageProposalMessages.get(decoratedMessage.id); diff --git a/application/single_app/static/js/chat/chat-messages.js b/application/single_app/static/js/chat/chat-messages.js index d2f068690..5b6ee1cb9 100644 --- a/application/single_app/static/js/chat/chat-messages.js +++ b/application/single_app/static/js/chat/chat-messages.js @@ -2223,6 +2223,10 @@ export function loadMessages(conversationId) { console.log(`Skipping deleted message: ${msg.id}`); return; } + // A workflow run's own post is replaced by its mirrored reply, which says the same. + if (msg.metadata && msg.metadata.superseded_by_workflow_reply) { + return; + } console.log(`[loadMessages Loop] -------- START Message ID: ${msg.id} --------`); console.log(`[loadMessages Loop] Role: ${msg.role}`); if (msg.role === "user") { diff --git a/application/v2_ui/src/components/chat/MessageList.tsx b/application/v2_ui/src/components/chat/MessageList.tsx index 00330207b..30318a732 100644 --- a/application/v2_ui/src/components/chat/MessageList.tsx +++ b/application/v2_ui/src/components/chat/MessageList.tsx @@ -72,6 +72,7 @@ import { isAiRequest, isAgentPostedMessage, isOwnMessage, + isSupersededByWorkflowReply, messageAuthorName, resolveReplyContext, } from '../../lib/sharedMessage'; @@ -1461,13 +1462,17 @@ export function MessageList() { * paid to generate must never end up visible nowhere. */ const { threadMessages, proposalImagesByMessage } = useMemo(() => { - const grouped = groupProposalImages(messages); + // A workflow run's own post is replaced by its mirrored reply, which says the same + // with the maps and sources attached. It stays loaded, so a reply quoting it still + // resolves. + const shown = messages.filter((message) => !isSupersededByWorkflowReply(message)); + const grouped = groupProposalImages(shown); if (grouped.size === 0) { - return { threadMessages: messages, proposalImagesByMessage: grouped }; + return { threadMessages: shown, proposalImagesByMessage: grouped }; } const claimed = new Set(); - for (const message of messages) { + for (const message of shown) { const candidates = message.role === 'assistant' ? grouped.get(message.id) : undefined; if (!candidates?.length) { continue; @@ -1481,7 +1486,7 @@ export function MessageList() { } } - const visible = messages.filter( + const visible = shown.filter( (message) => message.role !== 'image' || !claimed.has(message.id), ); diff --git a/application/v2_ui/src/lib/sharedMessage.ts b/application/v2_ui/src/lib/sharedMessage.ts index a26d6ea04..19f903556 100644 --- a/application/v2_ui/src/lib/sharedMessage.ts +++ b/application/v2_ui/src/lib/sharedMessage.ts @@ -134,3 +134,18 @@ export function isAgentPostedMessage(message: ChatMessage | undefined): boolean metadata?.content_format === 'markdown' ); } + +/** + * Whether a workflow run's mirrored reply has replaced this message in the thread. + * + * A run that creates a conversation and posts its own message there later mirrors its full + * reply into the same conversation, with the maps and sources its tools returned. The server + * then marks the run's post with `superseded_by_workflow_reply` + * (`_hide_run_posts_superseded_by_reply` in functions_workflow_runner.py). It stays stored, and + * in the AI's history, but showing it would repeat the reply in a form that reads as though the + * person had written it. + */ +export function isSupersededByWorkflowReply(message: ChatMessage | undefined): boolean { + const metadata = message?.metadata as { superseded_by_workflow_reply?: unknown } | undefined; + return Boolean(metadata?.superseded_by_workflow_reply); +} diff --git a/docs/explanation/features/V2_INLINE_MEDIA_AND_AGENT_MESSAGES.md b/docs/explanation/features/V2_INLINE_MEDIA_AND_AGENT_MESSAGES.md index 68cd7aff3..97119b327 100644 --- a/docs/explanation/features/V2_INLINE_MEDIA_AND_AGENT_MESSAGES.md +++ b/docs/explanation/features/V2_INLINE_MEDIA_AND_AGENT_MESSAGES.md @@ -68,6 +68,10 @@ players. Messages people type stay plain text. - `components/chat/MessageList.tsx` renders those messages with `AssistantMarkdown` in the neutral bubble (tables and links are unreadable on the accent colour), keeps them on the sender's side of the thread, and labels them "posted through an agent". +- Since 0.261.253, a workflow run's own post is hidden once the run's full reply is mirrored into the + conversation it created. The server marks the post `superseded_by_workflow_reply`, and + `isSupersededByWorkflowReply` leaves it out of the thread. See + [Workflow reply replaces the agent-posted message](../fixes/WORKFLOW_REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md). ### Configuration diff --git a/docs/explanation/fixes/WORKFLOW_REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md b/docs/explanation/fixes/WORKFLOW_REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md new file mode 100644 index 000000000..7aaf5aa5f --- /dev/null +++ b/docs/explanation/fixes/WORKFLOW_REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md @@ -0,0 +1,91 @@ +# Workflow Reply Replaces the Agent-Posted Message Fix + +Fixed in version: **0.261.253** + +## Issue Description + +An agent workflow can create a conversation and post into it through the Simple Chat action. An +alert-triage workflow, for example, creates a group conversation for the response team and posts +its opening briefing as the first message. When the run finishes, SimpleChat mirrors the run's +full reply into the same conversation, with the maps and tool sources attached. + +The conversation then opened with the same findings twice, each several screens long with the same +images: + +1. the run's own post, stored as the workflow user's message and labelled "posted through an + agent", so it read as though that person had written it; +2. the mirrored reply, from the agent, with the map and every source. + +The workflow's own conversation, **Workflow: *name***, also stayed in the chat list. A scheduled +workflow updates it on every run, so it kept moving to the top of the list above the conversation +people actually needed. + +## Root Cause Analysis + +- `_mirror_workflow_visualizations_to_created_conversations` mirrored the run's reply into each + created conversation, but nothing recorded that the run had already posted there. +- The run's post and the reply were separate messages that every client showed. +- Personal workflow conversations are created visible (only group workflow conversations start + hidden), and nothing changed that once the run delivered its result elsewhere. + +## Technical Details + +### Files Modified + +- `application/single_app/functions_workflow_runner.py` +- `application/v2_ui/src/lib/sharedMessage.ts` +- `application/v2_ui/src/components/chat/MessageList.tsx` +- `application/single_app/static/js/chat/chat-messages.js` +- `application/single_app/static/js/chat/chat-collaboration.js` +- `application/single_app/config.py` + +### Code Changes Summary + +- `_extract_run_posted_message_ids_from_citations` reads the run's own posts from its tool results: + the opening message a `create_*_conversation` call seeded (`seeded_initial_message`) and every + `add_conversation_message` call. Failed calls are ignored. +- After the reply is mirrored into a conversation, `_hide_run_posts_superseded_by_reply` marks + those posts with `metadata.superseded_by_workflow_reply` (`message_id` of the reply, + `workflow_id`, `superseded_at`). The mark is applied only to messages posted through the agent + action (`posted_via: agent_action`). The message stays stored, and stays in the conversation's AI + history. The step is best effort and never undoes the mirror. +- The mirrored reply is unchanged: it keeps the full content and every citation, maps included. +- `_hide_workflow_conversation_after_delivery` hides the workflow conversation the first time a run + delivers into a conversation it created, records `workflow_delivery_hidden_at`, and bumps the + owner's conversation cache version. A person who shows the conversation again is not overruled + by later runs. Runs that create no conversation leave it alone. +- V2 (`isSupersededByWorkflowReply` in `MessageList`) and both classic message loops leave a + superseded message out of the thread. It stays loaded, so a reply that quotes it still resolves. + +### Impact Analysis + +- The created conversation opens with the run's full reply, once. +- The workflow conversation remains available from the workflow alert's links and the classic + "show hidden conversations" toggle, and still records every run. +- Messages people write are never hidden, and agent posts made outside a workflow run are + unaffected. +- Conversations created before this version keep both messages until a new run delivers into + them. + +## Testing Approach + +- `functional_tests/test_workflow_reply_replaces_agent_posted_message.py` compiles the real runner + functions against in-memory stores. It covers group and personal deliveries, which messages are + hidden, a created conversation with no post of its own, a run that creates nothing, the one-time + hide of the workflow conversation, a retried mirror, a failed hide, and the classic message loops. +- `ui_tests/test_chat_workflow_results.py::test_a_runs_own_post_gives_way_to_its_mirrored_reply` + renders the real V2 chat page and checks that only the reply shows, while an agent post without + the mark still shows. + +## Validation + +- Before: a created group conversation opened with the briefing (about 7,400 px tall) followed by + the mirrored reply repeating it (about 7,300 px), and **Workflow: *name*** stayed at the top of the + chat list. +- After: the conversation opens with the reply, maps and sources included, and the workflow + conversation steps out of the chat list after the first delivery. + +## Related + +- [Workflow visualization conversation mirror fix](WORKFLOW_VISUALIZATION_CONVERSATION_MIRROR_FIX.md) +- [V2 inline media and agent-posted messages](../features/V2_INLINE_MEDIA_AND_AGENT_MESSAGES.md) diff --git a/docs/explanation/fixes/WORKFLOW_VISUALIZATION_CONVERSATION_MIRROR_FIX.md b/docs/explanation/fixes/WORKFLOW_VISUALIZATION_CONVERSATION_MIRROR_FIX.md index 21efde68f..b0ccba99f 100644 --- a/docs/explanation/fixes/WORKFLOW_VISUALIZATION_CONVERSATION_MIRROR_FIX.md +++ b/docs/explanation/fixes/WORKFLOW_VISUALIZATION_CONVERSATION_MIRROR_FIX.md @@ -51,4 +51,7 @@ The result was a text-only assistant reply that said a map had been created even ## Validation - Before: the workflow conversation and the created conversation both showed only text such as “Created map visualization.” -- After: the workflow conversation stores the visualization citation payload, and the created conversation receives a mirrored assistant message that can render the created visualization. \ No newline at end of file +- After: the workflow conversation stores the visualization citation payload, and the created conversation receives a mirrored assistant message that can render the created visualization. +## Later changes + +- **0.261.253**: when the run also posted its own message into the created conversation, that message is hidden once the mirrored reply arrives, and the workflow conversation is hidden from the chat list after the first delivery. See [Workflow reply replaces the agent-posted message](WORKFLOW_REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md). \ No newline at end of file diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index c240209dd..8ca0709de 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.253)** + +#### Bug Fixes + +* **Workflow Reply Shown Once in the Conversation It Created** + * An agent workflow that creates a conversation and posts its opening message there through the Simple Chat action no longer shows the same findings twice. Once the run's full reply, with its maps and sources, is mirrored into that conversation, the run's own post (labelled "posted through an agent") is left out of the thread in the classic and V2 chat views. The post stays stored and stays in the conversation's AI history. + * The workflow's own conversation, **Workflow: *name***, is hidden from the chat list the first time a run delivers its result into a conversation it created. It still records every run, and opens from the workflow alert's **Show more** links or the "show hidden conversations" toggle. If you show it again, later runs leave it visible. + * Messages people write are never hidden, and agent posts made outside a workflow run are unaffected. Conversations created before this version keep both messages until a new run delivers into them. + * (Ref: `functions_workflow_runner.py`, `sharedMessage.ts`, `MessageList.tsx`, `chat-messages.js`, `chat-collaboration.js`, `test_workflow_reply_replaces_agent_posted_message.py`, [Workflow Reply Replaces the Agent-Posted Message Fix](fixes/WORKFLOW_REPLY_REPLACES_AGENT_POSTED_MESSAGE_FIX.md)) + ### **(v0.261.252)** #### Bug Fixes diff --git a/docs/guides/create-a-workflow.md b/docs/guides/create-a-workflow.md index 52e658a2c..e3ccf32c2 100644 --- a/docs/guides/create-a-workflow.md +++ b/docs/guides/create-a-workflow.md @@ -947,6 +947,27 @@ either way. Workspace members who can't open your workflow see only **Created by a workflow**. See [Uploading and managing documents]({{ '/guides/upload-and-manage-documents/' | relative_url }}). +## Read results in a conversation the workflow creates + +An agent workflow can create a conversation as part of its work through the +Simple Chat action, for example a group conversation for a response team. +Starting in **0.261.253**, that conversation is where people read the result: + +- When the run finishes, its full reply is posted there with everything it found, + including maps and the sources its tools returned. +- If the agent also posted its own message there during the run, such as an + opening briefing, that message is hidden once the reply arrives. The + conversation doesn't show the same findings twice, once as if the person the + workflow runs as had written them. The message is still stored. +- The workflow's own conversation, **Workflow: *name***, is hidden from the chat + list the first time a run delivers its result this way. It still records every + run. Open it from the workflow alert's **Show more** links, or with the eye icon + that shows hidden conversations in the classic chat list. If you show it again, + later runs leave it visible. + +A workflow that doesn't create a conversation keeps its results in its own +conversation, as before. + ## Troubleshooting | Symptom | Likely cause | Fix | diff --git a/functional_tests/test_workflow_reply_replaces_agent_posted_message.py b/functional_tests/test_workflow_reply_replaces_agent_posted_message.py new file mode 100644 index 000000000..a1f10743d --- /dev/null +++ b/functional_tests/test_workflow_reply_replaces_agent_posted_message.py @@ -0,0 +1,479 @@ +#!/usr/bin/env python3 +# test_workflow_reply_replaces_agent_posted_message.py +""" +Functional test for a workflow's mirrored reply replacing the run's own posted message. +Version: 0.261.253 +Implemented in: 0.261.253 + +This test ensures that when a workflow run creates a conversation, posts its own message there +and then has its reply mirrored in: + +- the mirrored reply keeps the run's full content and every citation, maps included; +- the run's posted message stays stored but is marked superseded_by_workflow_reply, so clients + do not show it, while messages people wrote, and posts that were not made through the agent + action, are never marked; +- the workflow's own conversation is hidden the first time a run delivers into a conversation it + created, and is never hidden again once a person shows it; +- a failure while hiding never undoes the mirror, and a retried mirror changes nothing; +- the classic client skips a superseded message in both its personal and shared message loops. + +The real runner functions are compiled from functions_workflow_runner.py with Cosmos, the +collaboration store and logging replaced by in-memory fakes. +""" + +import ast +import copy +import logging +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent)) + +from test_support.versioning import assert_app_version_at_least # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "application" / "single_app" +# Appended, so the application's modules never shadow the test packages. +sys.path.append(str(APP_ROOT)) + +from collaboration_models import ( # noqa: E402 + COLLABORATION_KIND, + GROUP_MULTI_USER_CHAT_TYPE, + PERSONAL_MULTI_USER_CHAT_TYPE, + normalize_collaboration_user, +) + +RUNNER_FILE = APP_ROOT / "functions_workflow_runner.py" +MINIMUM_VERSION = "0.261.253" +RUNNER_FUNCTIONS = { + "_extract_created_conversation_docs_from_citations", + "_is_collaboration_target_conversation", + "_extract_run_posted_message_ids_from_citations", + "_hide_run_posts_superseded_by_reply", + "_hide_workflow_conversation_after_delivery", + "_mirror_workflow_visualizations_to_created_conversations", +} +RUNNER_CONSTANTS = {"WORKFLOW_REPLY_SUPERSEDED_METADATA_KEY"} + +NOW = "2026-10-03T14:00:00+00:00" +WORKFLOW = {"id": "workflow-1", "name": "Alert watch", "user_id": "owner-1", "runner_type": "agent"} +WORKFLOW_CONVERSATION_ID = "workflow-conversation-1" +FULL_REPLY = "## Alert triage\n\nEverything the run found, with its map and sources." +MAP_CITATION = { + "plugin_name": "AzureMapsOpenLayersPlugin", + "function_name": "create_map_visualization", + "success": True, + "function_result": {"render_type": "azure_maps_openlayers", "map_payload": {"title": "Route", "markers": []}}, +} +LOOKUP_CITATION = { + "plugin_name": "toll_plate_reads", + "function_name": "getPlateTrack", + "success": True, + "function_result": {"results": [{"read_id": "R-1"}]}, +} + + +class NotFound(Exception): + """Stands in for CosmosResourceNotFoundError.""" + + +class FakeContainer: + """A Cosmos container keyed by (partition key, id), recording every write.""" + + def __init__(self, partition_field): + self.partition_field = partition_field + self.items = {} + self.upserts = [] + self.fail_upserts_for = set() + + def seed(self, item): + self.items[(item[self.partition_field], item["id"])] = copy.deepcopy(item) + + def get(self, partition_key, item_id): + return copy.deepcopy(self.items.get((partition_key, item_id))) + + def read_item(self, item, partition_key): + try: + return copy.deepcopy(self.items[(partition_key, item)]) + except KeyError: + raise NotFound(item) from None + + def upsert_item(self, item): + if item["id"] in self.fail_upserts_for: + raise RuntimeError("Cosmos write failed") + self.upserts.append(copy.deepcopy(item)) + self.seed(item) + return item + + +def _load_runner(namespace): + tree = ast.parse(RUNNER_FILE.read_text(encoding="utf-8"), filename=str(RUNNER_FILE)) + nodes = [] + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.name in RUNNER_FUNCTIONS: + nodes.append(node) + elif isinstance(node, ast.Assign) and any( + isinstance(target, ast.Name) and target.id in RUNNER_CONSTANTS for target in node.targets + ): + nodes.append(node) + found = {node.name for node in nodes if isinstance(node, ast.FunctionDef)} + assert found == RUNNER_FUNCTIONS, f"Missing runner functions: {sorted(RUNNER_FUNCTIONS - found)}" + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(RUNNER_FILE), "exec"), namespace) + return namespace + + +class MirrorHarness: + """The real mirror, with the stores it writes to held in memory.""" + + def __init__(self): + self.collaboration_messages = FakeContainer("conversation_id") + self.personal_messages = FakeContainer("conversation_id") + self.conversations = FakeContainer("id") + self.cache_bumps = [] + self.notifications = [] + self.mirrored_sources = [] + self.warnings = [] + self.runner = _load_runner({ + "logging": logging, + "CosmosResourceNotFoundError": NotFound, + "cosmos_collaboration_messages_container": self.collaboration_messages, + "cosmos_messages_container": self.personal_messages, + "cosmos_conversations_container": self.conversations, + "COLLABORATION_KIND": COLLABORATION_KIND, + "GROUP_MULTI_USER_CHAT_TYPE": GROUP_MULTI_USER_CHAT_TYPE, + "PERSONAL_MULTI_USER_CHAT_TYPE": PERSONAL_MULTI_USER_CHAT_TYPE, + "normalize_collaboration_user": normalize_collaboration_user, + "_utc_now_iso": lambda: NOW, + "log_event": self.log_event, + "bump_conversation_cache_version": self.bump_cache, + "get_collaboration_conversation": lambda conversation_id: { + "id": conversation_id, "chat_type": GROUP_MULTI_USER_CHAT_TYPE, + }, + "mirror_source_message_to_collaboration": self.mirror_to_collaboration, + "create_collaboration_message_notifications": lambda conversation, message: self.notifications.append( + message["id"], + ), + "_mirror_assistant_message_to_personal_conversation": self.mirror_to_personal, + }) + self.conversations.seed({ + "id": WORKFLOW_CONVERSATION_ID, "user_id": "owner-1", "chat_type": "workflow", "is_hidden": False, + }) + + # Collaborators --------------------------------------------------------------------------- + + def log_event(self, message, extra=None, level=logging.INFO, **kwargs): + if level >= logging.WARNING: + self.warnings.append(message) + + def bump_cache(self, user_id, reason="conversation_changed"): + self.cache_bumps.append((user_id, reason)) + + def mirror_to_collaboration(self, conversation, source_doc, sender, reply_to_message_id=None, extra_metadata=None): + """Like the real mirror: one mirrored message per source message, found again on a retry.""" + mirror_id = f"{conversation['id']}_mirror" + existing = self.collaboration_messages.get(conversation["id"], mirror_id) + if existing: + return existing, conversation, False + self.mirrored_sources.append(copy.deepcopy(source_doc)) + mirrored = { + "id": mirror_id, "conversation_id": conversation["id"], "role": "assistant", + "content": source_doc["content"], "agent_citations": source_doc["agent_citations"], + "metadata": {**(extra_metadata or {}), "sender": sender}, + } + self.collaboration_messages.seed(mirrored) + return mirrored, conversation, True + + def mirror_to_personal(self, workflow, source_doc, created_conversation, citations): + self.mirrored_sources.append({**copy.deepcopy(source_doc), "agent_citations": copy.deepcopy(citations)}) + mirrored = { + "id": f"{created_conversation['id']}-reply", "conversation_id": created_conversation["id"], + "role": "assistant", "content": source_doc["content"], "agent_citations": citations, + "metadata": {"source": "workflow_mirror"}, + } + self.personal_messages.seed(mirrored) + return mirrored + + # The run --------------------------------------------------------------------------------- + + def deliver(self, citations): + """Mirror a finished run's reply, the way the runner's mirror_outputs unit does.""" + source_reply = { + "id": "reply-1", "conversation_id": WORKFLOW_CONVERSATION_ID, "role": "assistant", + "content": FULL_REPLY, "agent_citations": copy.deepcopy(citations), + "hybrid_citations": [], "web_search_citations": [], + "metadata": {"workflow": {"run_id": "run-1", "trigger_source": "scheduled"}}, + } + return self.runner["_mirror_workflow_visualizations_to_created_conversations"]( + WORKFLOW, source_reply, {"agent_citations": copy.deepcopy(citations)}, + ) + + def marker(self, container, conversation_id, message_id): + doc = container.get(conversation_id, message_id) or {} + return (doc.get("metadata") or {}).get("superseded_by_workflow_reply") + + def workflow_conversation(self): + return self.conversations.get(WORKFLOW_CONVERSATION_ID, WORKFLOW_CONVERSATION_ID) + + +def agent_post(conversation_id, message_id, content="Opening briefing"): + return { + "id": message_id, "conversation_id": conversation_id, "role": "user", "content": content, + "metadata": {"posted_via": "agent_action", "content_format": "markdown", + "sender": {"user_id": "owner-1", "display_name": "Workflow owner"}}, + } + + +def person_message(conversation_id, message_id, content="Looking at it now."): + return { + "id": message_id, "conversation_id": conversation_id, "role": "user", "content": content, + "metadata": {"sender": {"user_id": "member-1", "display_name": "Team member"}}, + } + + +def created(function_name, conversation, seeded_message=None, success=True): + result = {"success": success, "conversation": conversation} + if seeded_message is not None: + result.update(message=seeded_message, seeded_initial_message=True) + return { + "plugin_name": "SimpleChatPlugin", "function_name": function_name, + "success": success, "function_result": result, + } + + +def added(conversation, message, success=True): + return { + "plugin_name": "SimpleChatPlugin", "function_name": "add_conversation_message", "success": success, + "function_result": {"success": success, "conversation": conversation, "message": message}, + } + + +GROUP = {"id": "group-1", "chat_type": GROUP_MULTI_USER_CHAT_TYPE, "conversation_kind": COLLABORATION_KIND} +PERSONAL = {"id": "personal-1", "chat_type": "personal_single_user"} + + +def test_group_conversation_keeps_the_full_reply_and_hides_the_run_post(): + """The created group conversation shows the run's whole reply, once.""" + print("Testing the group conversation delivery...") + harness = MirrorHarness() + seed = agent_post("group-1", "group-1_seed") + harness.collaboration_messages.seed(seed) + citations = [created("create_group_conversation", GROUP, seeded_message=seed), MAP_CITATION, LOOKUP_CITATION] + + delivered = harness.deliver(citations) + + assert delivered == ["group-1_mirror"], delivered + [source] = harness.mirrored_sources + assert source["content"] == FULL_REPLY + assert source["agent_citations"] == citations, "Every citation, the map included, travels with the reply." + assert harness.notifications == ["group-1_mirror"] + + stored_seed = harness.collaboration_messages.get("group-1", "group-1_seed") + assert stored_seed["content"] == seed["content"], "The run's post stays stored." + assert stored_seed["metadata"]["posted_via"] == "agent_action" + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_seed") == { + "message_id": "group-1_mirror", "workflow_id": "workflow-1", "superseded_at": NOW, + } + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_mirror") is None + + conversation = harness.workflow_conversation() + assert conversation["is_hidden"] is True + assert conversation["workflow_delivery_hidden_at"] == NOW + assert harness.cache_bumps == [("owner-1", "workflow_conversation_hidden_after_delivery")] + assert harness.warnings == [] + print(" Group delivery passed.") + return True + + +def test_personal_conversation_hides_the_run_post_after_its_reply(): + """A created personal conversation follows the same rule on the personal message store.""" + print("Testing the personal conversation delivery...") + harness = MirrorHarness() + seed = agent_post("personal-1", "personal-seed") + harness.personal_messages.seed(seed) + + delivered = harness.deliver([created("create_personal_conversation", PERSONAL, seeded_message=seed), MAP_CITATION]) + + assert delivered == ["personal-1-reply"], delivered + assert harness.marker(harness.personal_messages, "personal-1", "personal-seed")["message_id"] == "personal-1-reply" + assert harness.collaboration_messages.upserts == [], "The shared store is never touched for a personal conversation." + assert harness.workflow_conversation()["is_hidden"] is True + print(" Personal delivery passed.") + return True + + +def test_only_the_runs_own_agent_posts_are_hidden(): + """Later agent posts are hidden too; people's messages and unmarked posts never are.""" + print("Testing which messages are hidden...") + harness = MirrorHarness() + seed = agent_post("group-1", "group-1_seed") + follow_up = agent_post("group-1", "group-1_follow_up", content="Meeting booked for 6 p.m.") + reply = person_message("group-1", "group-1_person") + # Claimed by a tool result, but not written through the agent action: never hidden. + unmarked = person_message("group-1", "group-1_unmarked", content="Typed by a person.") + for message in (seed, follow_up, reply, unmarked): + harness.collaboration_messages.seed(message) + + harness.deliver([ + created("create_group_conversation", GROUP, seeded_message=seed), + added(GROUP, follow_up), + added(GROUP, unmarked), + added(GROUP, {"id": "group-1_failed", "conversation_id": "group-1"}, success=False), + MAP_CITATION, + ]) + + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_seed") + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_follow_up") + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_person") is None + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_unmarked") is None + written = sorted(item["id"] for item in harness.collaboration_messages.upserts) + assert written == ["group-1_follow_up", "group-1_seed"], written + print(" Hidden-message selection passed.") + return True + + +def test_a_conversation_created_without_a_post_still_gets_the_reply(): + """Without a post of the run's own there is nothing to hide, and the reply is still delivered.""" + print("Testing a created conversation with no opening post...") + harness = MirrorHarness() + person = person_message("group-1", "group-1_person") + harness.collaboration_messages.seed(person) + + delivered = harness.deliver([created("create_group_conversation", GROUP), MAP_CITATION]) + + assert delivered == ["group-1_mirror"], delivered + assert harness.collaboration_messages.upserts == [] + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_person") is None + assert harness.workflow_conversation()["is_hidden"] is True, "The reply was delivered, so the run's record steps back." + print(" No-post delivery passed.") + return True + + +def test_a_run_that_creates_nothing_leaves_its_conversation_alone(): + """No created conversation, no delivery: the workflow conversation is where the result lives.""" + print("Testing a run that creates no conversation...") + harness = MirrorHarness() + + delivered = harness.deliver([MAP_CITATION, LOOKUP_CITATION]) + failed = harness.deliver([created("create_group_conversation", {}, success=False)]) + + assert delivered == [] and failed == [] + assert harness.workflow_conversation()["is_hidden"] is False + assert harness.cache_bumps == [] + print(" No-delivery run passed.") + return True + + +def test_the_workflow_conversation_is_hidden_once_and_a_person_can_show_it_again(): + """Later runs never overrule a person who showed the conversation again.""" + print("Testing the one-time hide of the workflow conversation...") + harness = MirrorHarness() + harness.deliver([created("create_group_conversation", GROUP), MAP_CITATION]) + assert harness.workflow_conversation()["is_hidden"] is True + + shown_again = harness.workflow_conversation() + shown_again["is_hidden"] = False + harness.conversations.seed(shown_again) + harness.deliver([created("create_personal_conversation", PERSONAL), MAP_CITATION]) + + conversation = harness.workflow_conversation() + assert conversation["is_hidden"] is False + assert conversation["workflow_delivery_hidden_at"] == NOW + assert len(harness.cache_bumps) == 1, harness.cache_bumps + + hide = harness.runner["_hide_workflow_conversation_after_delivery"] + harness.conversations.seed({"id": "already-hidden", "user_id": "u", "chat_type": "workflow", "is_hidden": True}) + harness.conversations.seed({"id": "ordinary-chat", "user_id": "u", "chat_type": "personal_single_user"}) + assert hide("already-hidden") is False + assert "workflow_delivery_hidden_at" not in harness.conversations.get("already-hidden", "already-hidden") + assert hide("ordinary-chat") is False + assert "is_hidden" not in harness.conversations.get("ordinary-chat", "ordinary-chat") + assert hide("missing") is False and hide("") is False + print(" One-time hide passed.") + return True + + +def test_a_retried_mirror_changes_nothing(): + """The mirror is found again on a retry; nothing is notified, posted or re-stamped twice.""" + print("Testing a retried mirror...") + harness = MirrorHarness() + seed = agent_post("group-1", "group-1_seed") + harness.collaboration_messages.seed(seed) + citations = [created("create_group_conversation", GROUP, seeded_message=seed), MAP_CITATION] + + harness.deliver(citations) + writes = len(harness.collaboration_messages.upserts) + retried = harness.deliver(citations) + + assert retried == [], "Nothing new was mirrored." + assert harness.notifications == ["group-1_mirror"] + assert len(harness.collaboration_messages.upserts) == writes, "An already hidden post is not rewritten." + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_seed")["message_id"] == "group-1_mirror" + print(" Retry passed.") + return True + + +def test_a_failure_while_hiding_never_undoes_the_delivery(): + """Hiding is best effort: the post stays visible and the reply is still delivered.""" + print("Testing a failed hide...") + harness = MirrorHarness() + seed = agent_post("group-1", "group-1_seed") + harness.collaboration_messages.seed(seed) + harness.collaboration_messages.fail_upserts_for.add("group-1_seed") + + delivered = harness.deliver([created("create_group_conversation", GROUP, seeded_message=seed), MAP_CITATION]) + + assert delivered == ["group-1_mirror"], delivered + assert harness.marker(harness.collaboration_messages, "group-1", "group-1_seed") is None + assert any("Failed to hide a run-posted message" in warning for warning in harness.warnings), harness.warnings + assert harness.workflow_conversation()["is_hidden"] is True + print(" Failed hide passed.") + return True + + +def test_classic_message_loops_skip_superseded_messages(): + """Both classic loops leave a superseded message out before rendering it.""" + print("Testing the classic message loops...") + personal = (APP_ROOT / "static" / "js" / "chat" / "chat-messages.js").read_text(encoding="utf-8") + shared = (APP_ROOT / "static" / "js" / "chat" / "chat-collaboration.js").read_text(encoding="utf-8") + + personal_loop = personal[personal.index("data.messages.forEach((msg) => {"):] + assert personal_loop.index("msg.metadata.superseded_by_workflow_reply") < personal_loop.index("appendMessage(") + shared_loop = shared[shared.index("messages.forEach(message => {"):] + skip = shared_loop.index("message?.metadata?.superseded_by_workflow_reply") + assert skip < shared_loop.index("renderCollaborationMessage(decoratedMessage)") + # Cached before returning, so a later reply that quotes it still resolves. + assert shared_loop.index("cacheCollaborationMessage(message);", skip) < shared_loop.index("return;", skip) + print(" Classic loops passed.") + return True + + +def test_version(): + assert_app_version_at_least(MINIMUM_VERSION) + return True + + +if __name__ == "__main__": + tests = [ + test_group_conversation_keeps_the_full_reply_and_hides_the_run_post, + test_personal_conversation_hides_the_run_post_after_its_reply, + test_only_the_runs_own_agent_posts_are_hidden, + test_a_conversation_created_without_a_post_still_gets_the_reply, + test_a_run_that_creates_nothing_leaves_its_conversation_alone, + test_the_workflow_conversation_is_hidden_once_and_a_person_can_show_it_again, + test_a_retried_mirror_changes_nothing, + test_a_failure_while_hiding_never_undoes_the_delivery, + test_classic_message_loops_skip_superseded_messages, + test_version, + ] + results = [] + for test in tests: + try: + results.append(bool(test())) + except Exception as exc: # noqa: BLE001 - report every failure + import traceback + + print(f"FAILED {test.__name__}: {exc}") + traceback.print_exc() + results.append(False) + print(f"\nResults: {sum(results)}/{len(results)} tests passed") + sys.exit(0 if all(results) else 1) diff --git a/ui_tests/test_chat_workflow_results.py b/ui_tests/test_chat_workflow_results.py index d58b80dcc..90f41ee30 100644 --- a/ui_tests/test_chat_workflow_results.py +++ b/ui_tests/test_chat_workflow_results.py @@ -1,8 +1,9 @@ # test_chat_workflow_results.py """ Browser regressions for asking chat about a finished workflow run's stored result. -Version: 0.261.214 +Version: 0.261.253 Implemented in: 0.261.214 +A run's own post hidden once its reply is mirrored in: 0.261.253 Exercises the real V2 chat page, composer, chat store, run history and workflow alert card, bundled by fixtures/workflow_results, against a fake server. The fake answers with the real @@ -12,8 +13,9 @@ personal runs only, the composer chip, a follow-up turn that inherits the run, refusals on the descriptor read and on the stream (including a result changed by resume-failed), a question sent while Ask in chat is still opening, the alert card's Ask about this, the -setting turned off, reopening a chat whose latest answer used a result or was masked, and a -user-authored workflow name that must stay text. No live application data is read or +setting turned off, reopening a chat whose latest answer used a result or was masked, a +user-authored workflow name that must stay text, and a conversation a run created, where the +run's own post gives way to its mirrored reply. No live application data is read or modified; the existing Azure Playwright connection fixture also supports a local browser. """ @@ -1006,3 +1008,48 @@ def test_a_workflow_name_is_shown_as_text(tab, server): ) expect(tab.page.locator('img[src="x"]')).to_have_count(0) assert tab.page.evaluate("() => window.__xss") is None + + +# A conversation a run created ------------------------------------------------------------------ + + +def test_a_runs_own_post_gives_way_to_its_mirrored_reply(tab, server): + """ + The run posted a briefing into the conversation it created, then its whole reply was + mirrored in. The post is stored and marked superseded_by_workflow_reply + (functions_workflow_runner), so only the reply shows. An agent post without the marker + still shows, labelled as posted through an agent. + """ + thread = {"thread_id": "conv-run-thread-1", "previous_thread_id": None, "active_thread": True, + "thread_attempt": 1} + agent_posted = {"posted_via": "agent_action", "content_format": "markdown"} + server.conversations["conv-run"] = [ + { + "id": "conv-run_post", "conversation_id": "conv-run", "role": "user", + "content": "Opening briefing the run posted.", "timestamp": "2026-05-05T15:00:00Z", + "metadata": {**agent_posted, "superseded_by_workflow_reply": { + "message_id": "conv-run_reply", "workflow_id": WORKFLOW_ID, + "superseded_at": "2026-05-05T15:04:00Z", + }}, + }, + { + "id": "conv-run_reply", "conversation_id": "conv-run", "role": "assistant", + "content": "The run's full reply, with everything it found.", "timestamp": "2026-05-05T15:04:00Z", + "model_deployment_name": "gpt-4o", "augmented": False, "hybrid_citations": [], + "web_search_citations": [], "agent_citations": [], + "metadata": {"source": "workflow_mirror", "thread_info": thread}, + }, + { + "id": "conv-run_note", "conversation_id": "conv-run", "role": "user", + "content": "A later note an agent posted.", "timestamp": "2026-05-05T15:06:00Z", + "metadata": dict(agent_posted), + }, + ] + server.titles["conv-run"] = "Created by a run" + tab.open("/chat?conversationId=conv-run") + + expect(tab.message("conv-run_reply")).to_contain_text("The run's full reply, with everything it found.") + expect(tab.message("conv-run_note")).to_contain_text("A later note an agent posted.") + expect(tab.message("conv-run_note")).to_contain_text("Posted through an agent") + expect(tab.message("conv-run_post")).to_have_count(0) + expect(tab.messages_with("Opening briefing the run posted.")).to_have_count(0)