Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions application/single_app/functions_workflow_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
TABULAR_EXTENSIONS,
VERSION,
cognitive_services_scope,
cosmos_collaboration_messages_container,

Check warning on line 63 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains secret or sensitive data source marker. Recommendation%3A Pair this source with any nearby network, logging, serialization, or process execution sink before approving.
cosmos_conversations_container,
cosmos_group_documents_container,
cosmos_messages_container,
Expand Down Expand Up @@ -105,6 +106,7 @@
get_collaboration_conversation,
mirror_source_message_to_collaboration,
)
from functions_conversation_cache import bump_conversation_cache_version

Check warning on line 109 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
from functions_generated_file_exports import (
normalize_complete_xml_artifact_payload,
normalize_xml_artifact_payload,
Expand Down Expand Up @@ -4763,6 +4765,139 @@
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

Check warning on line 4769 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# do not show it: the reply carries the same findings with the maps and sources the tools

Check warning on line 4770 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
# 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):

Check warning on line 4775 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"""Return the ids of the messages a run posted itself, keyed by conversation id.

Check warning on line 4776 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.

Covers the opening message a create call seeded and every add_conversation_message call.

Check warning on line 4778 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
The ids come from the tool results, so only messages this run posted can match.

Check warning on line 4779 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"""
seeding_function_names = {
'create_group_conversation',

Check warning on line 4782 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
'create_personal_collaboration_conversation',

Check warning on line 4783 in application/single_app/functions_workflow_runner.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
'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()
Expand Down Expand Up @@ -4905,6 +5040,7 @@
'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:
Expand All @@ -4928,6 +5064,14 @@
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,
Expand All @@ -4937,6 +5081,13 @@
)
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}',
Expand All @@ -4949,6 +5100,9 @@
exceptionTraceback=True,
)

if mirrored_message_ids:
_hide_workflow_conversation_after_delivery(source_conversation_id)

return mirrored_message_ids


Expand Down
6 changes: 6 additions & 0 deletions application/single_app/static/js/chat/chat-collaboration.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 4 additions & 0 deletions application/single_app/static/js/chat/chat-messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
13 changes: 9 additions & 4 deletions application/v2_ui/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ import {
isAiRequest,
isAgentPostedMessage,
isOwnMessage,
isSupersededByWorkflowReply,
messageAuthorName,
resolveReplyContext,
} from '../../lib/sharedMessage';
Expand Down Expand Up @@ -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<string>();
for (const message of messages) {
for (const message of shown) {
const candidates = message.role === 'assistant' ? grouped.get(message.id) : undefined;
if (!candidates?.length) {
continue;
Expand All @@ -1481,7 +1486,7 @@ export function MessageList() {
}
}

const visible = messages.filter(
const visible = shown.filter(
(message) => message.role !== 'image' || !claimed.has(message.id),
);

Expand Down
15 changes: 15 additions & 0 deletions application/v2_ui/src/lib/sharedMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading