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 @@ -96,7 +96,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.250.226"
VERSION = "0.250.227"
IS_DEVELOPMENT = is_development_env_enabled()

SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax')
Expand Down
58 changes: 25 additions & 33 deletions application/single_app/route_backend_collaboration.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from flask import Response, current_app, jsonify, redirect, request, session, stream_with_context

from config import *
from collaboration_models import MEMBERSHIP_STATUS_PENDING, MESSAGE_KIND_AI_REQUEST, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso
from collaboration_models import COLLABORATION_KIND, MEMBERSHIP_STATUS_PENDING, MESSAGE_KIND_AI_REQUEST, add_seconds_to_iso, normalize_collaboration_user, utc_now_iso
from functions_appinsights import log_event
from functions_authentication import *
from functions_collaboration import (
Expand Down Expand Up @@ -1471,6 +1471,23 @@
message_content,
)

def collaboration_stream_error(error_message, **extra_fields):
"""Serialize a stream error that stays attributed to this shared conversation.

Check warning on line 1475 in application/single_app/route_backend_collaboration.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.

Every failure in this bridge belongs to the collaboration conversation, not the

Check warning on line 1477 in application/single_app/route_backend_collaboration.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.
hidden source conversation, so ``conversation_kind`` is always included. The

Check warning on line 1478 in application/single_app/route_backend_collaboration.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.

Check warning on line 1478 in application/single_app/route_backend_collaboration.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
browser recovery path keys off it to reload through the collaboration endpoint
instead of the personal one, which does not know this conversation id.

Check warning on line 1480 in application/single_app/route_backend_collaboration.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 _serialize_stream_error(
error_message,
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,

Check warning on line 1486 in application/single_app/route_backend_collaboration.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.
conversation_kind=COLLABORATION_KIND,

Check warning on line 1487 in application/single_app/route_backend_collaboration.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.
**extra_fields,
)

def generate_stream():
try:
yield build_user_message_persisted_stream_event(
Expand All @@ -1487,12 +1504,7 @@
},
level=logging.ERROR,
)
yield _serialize_stream_error(
'Chat streaming endpoint is unavailable',
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
)
yield collaboration_stream_error('Chat streaming endpoint is unavailable')
return

buffer = ''
Expand All @@ -1506,11 +1518,8 @@
error_payload = internal_response.get_json(silent=True) or {}
except Exception:
error_payload = {}
yield _serialize_stream_error(
yield collaboration_stream_error(
error_payload.get('error') or error_payload.get('message') or 'Failed to start collaboration AI workflow',
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
)
return

Expand Down Expand Up @@ -1542,12 +1551,9 @@
and stream_payload.get('message_id')
)
):
return _serialize_stream_error(
return collaboration_stream_error(
stream_payload.get('error'),
partial_content=stream_payload.get('partial_content'),
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
)
if stream_payload.get('error'):
stream_payload['done'] = True
Expand All @@ -1572,11 +1578,8 @@
return f'data: {json.dumps(make_json_serializable(transformed_payload))}\n\n'

if not source_message_id:
return _serialize_stream_error(
return collaboration_stream_error(
'AI workflow completed without a source assistant message',
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
)

source_user_message_id = str(stream_payload.get('user_message_id') or '').strip()
Expand All @@ -1599,11 +1602,8 @@
try:
source_message_doc = _read_source_message_doc(source_conversation_id, source_message_id)
except CosmosResourceNotFoundError:
return _serialize_stream_error(
return collaboration_stream_error(
'Failed to load the generated assistant response',
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
)

collaboration_conversation_doc = updated_conversation_doc
Expand Down Expand Up @@ -1633,11 +1633,8 @@
},
)
if not mirrored_message_doc:
return _serialize_stream_error(
return collaboration_stream_error(
'Failed to mirror the assistant response into the collaboration conversation',
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
)

create_collaboration_message_notifications(final_conversation_doc, mirrored_message_doc)
Expand Down Expand Up @@ -1710,12 +1707,7 @@
level=logging.ERROR,
exceptionTraceback=True,
)
yield _serialize_stream_error(
'Failed to stream collaborative AI response',
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
)
yield collaboration_stream_error('Failed to stream collaborative AI response')

return Response(stream_with_context(generate_stream()), mimetype='text/event-stream')
except CosmosResourceNotFoundError:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Shared (Multi-User) Conversation Reload and Streaming Fix

**Fixed in version: 0.250.224**
**Hardening added in version: 0.250.227**
**Tracking issue: [#1281](https://github.com/microsoft/simplechat/issues/1281)**

## Issue Description
Expand Down Expand Up @@ -183,3 +184,56 @@

* Fix note: this also restores group collaborative conversations, which use the same stream bridge.
* Feature documentation: `docs/explanation/features/COLLABORATIVE_CONVERSATIONS_FOUNDATION.md`

## Follow-up Hardening (0.250.227)

Three further defects were found while tracing this bug. None of them caused the reported symptoms, so they were kept out of the original fix.

### Stream errors are now always attributed to the shared conversation

Check warning on line 192 in docs/explanation/fixes/COLLABORATION_MULTI_USER_RELOAD_AND_STREAM_FIX.md

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.

`chat-streaming.js` chooses its recovery endpoint from `conversation_kind`:

Check warning on line 194 in docs/explanation/fixes/COLLABORATION_MULTI_USER_RELOAD_AND_STREAM_FIX.md

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.

```js
if (data.conversation_kind === 'collaborative' && ...loadConversationMessages) {

Check warning on line 197 in docs/explanation/fixes/COLLABORATION_MULTI_USER_RELOAD_AND_STREAM_FIX.md

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.
// collaboration endpoint
} else {
loadMessages(data.conversation_id); // personal endpoint, 404s for shared conversations
}
```

None of the seven `_serialize_stream_error()` call sites in the shared stream bridge set `conversation_kind`, so a shared conversation would fall into the `else` branch and hit the same 404 this fix removed.

It could not fire in practice, because the surrounding guard also requires `message_id` and those error payloads never carried one. It was a latent trap rather than a live defect: adding `message_id` to an error payload — a natural change — would have reintroduced the bug.

Rather than adding the field to seven call sites, all shared stream failures now funnel through a single nested helper that cannot omit it:

```python
def collaboration_stream_error(error_message, **extra_fields):
"""Serialize a stream error that stays attributed to this shared conversation."""
return _serialize_stream_error(
error_message,
user_message_id=serialized_user_message.get('id'),
message_persisted=True,
conversation_id=conversation_id,
conversation_kind=COLLABORATION_KIND,
**extra_fields,
)
```

`test_collaboration_stream_errors_always_carry_conversation_kind` walks the AST of `stream_collaboration_message_api` and asserts exactly one raw `_serialize_stream_error` call remains, that it sets `conversation_kind=COLLABORATION_KIND`, and that every failure path routes through the helper. Adding a new error path without the tag now fails the test.

### Stale `@app.route` assertions repaired across the test suite

The Blueprint migration left production with a single `@app.route` decorator — an example inside a `swagger_wrapper.py` docstring — while 82 test assertions across 40 files still expected the old form.

This is how the streaming defect shipped. `test_collaboration_shared_ai_workflow.py` existed to guard this exact bridge, but broke on line 35 (`@app.route`) and died before reaching line 37, which checked the endpoint lookup. The test that should have caught the bug was already red for an unrelated reason.

59 assertions across 32 files were rewritten to `@bp.route`, each verified against a real `@bp.route` path in `application/single_app` before being changed. 14 occurrences were deliberately left alone because no matching production route exists — those point at routes that appear to have been removed or renamed, which is a different problem and must not be papered over with a passing assertion.

Measured effect on the affected files: **47 failures to 34, with zero newly broken.**

### Dead post-stream reload guard (tracked separately)

`chat-streaming.js:1449` and `:1515` guard on `typeof window.chatMessages?.loadMessages === 'function'`, but `loadMessages` is not among the six functions `chat-messages.js` assigns to `window.chatMessages`, and `git log -S` confirms it never was. The guard has been dead since commit `54e37c87`.

The backend sets `reload_messages: true` when an agent plugin persists extra message documents into Cosmos, so those messages stay invisible until a manual reload. Impact is probably narrow — the final payload renders `image_url` separately — but sizing it needs a repro, and switching on a path that has never executed in production is not a safe blind change. Filed as [#1286](https://github.com/microsoft/simplechat/issues/1286).
14 changes: 14 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

For feature-focused and fix-focused drill-downs by version, see [Features by Version](/explanation/features/) and [Fixes by Version](/explanation/fixes/).

### **(v0.250.227)**

#### Bug Fixes

* **Shared Conversation Stream Errors Stay Attached to the Shared Conversation**
* Follow-up hardening to the v0.250.224 shared conversation fix. When an AI request in a shared conversation failed, the error the browser received did not say which kind of conversation it belonged to, so the recovery path could have reloaded from the personal endpoint and produced the same "Conversation not found" error that was just fixed.
* It could not actually happen yet because of an unrelated guard, but it would have come back the moment anyone added a message id to those errors. All shared stream failures now go through a single serializer that always tags the conversation, and a test walks the code to prove no failure path can skip it.
* (Ref: #1281, `route_backend_collaboration.py`, `chat-streaming.js`, collaborative AI streaming)

* **Repaired Route Assertions Across the Test Suite**
* The recent Blueprint security hardening renamed how routes are declared, but 82 assertions across 40 test files still checked for the old form. Those tests were failing on the rename before they ever reached the behavior they were written to protect.
* This is how the shared conversation streaming bug reached users: the test guarding that exact code path was already red for an unrelated reason. 59 assertions across 32 files were corrected, each verified against a real route first. 14 were deliberately left alone because they point at routes that no longer exist, which is a separate issue worth investigating rather than hiding.
* (Ref: #1281, `functional_tests/`, Blueprint route registration)

### **(v0.250.226)**

#### Bug Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ def test_release_notifications_backend_and_settings_markers():
raise AssertionError(f'Missing release notifications frontend markers: {missing_frontend}')

backend_markers = [
"@app.route('/api/admin/settings/release_notifications_registration', methods=['POST'])",
"@bp.route('/api/admin/settings/release_notifications_registration', methods=['POST'])",
'def release_notifications_registration():',
"'release_notifications_registered': True",
'log_admin_release_notifications_registration(',
Expand Down
2 changes: 1 addition & 1 deletion functional_tests/test_admin_send_feedback_tab.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ def test_send_feedback_javascript_and_backend():
raise AssertionError(f'Missing sidebar tab activation markers: {missing_sidebar}')

backend_markers = [
"@app.route('/api/admin/settings/send_feedback_email', methods=['POST'])",
"@bp.route('/api/admin/settings/send_feedback_email', methods=['POST'])",
'def send_feedback_email():',
'log_admin_feedback_email_submission('
]
Expand Down
2 changes: 1 addition & 1 deletion functional_tests/test_agent_citation_full_results_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def test_route_exposes_agent_citation_artifact_endpoint():

route_source = read_file_text(ROUTE_FILE)
required_snippets = [
"@app.route('/api/conversation/<conversation_id>/agent-citation/<artifact_id>', methods=['GET'])",
"@bp.route('/api/conversation/<conversation_id>/agent-citation/<artifact_id>', methods=['GET'])",
'build_message_artifact_payload_map',
"artifact_payload_map.get(str(artifact_id or ''))",
"return jsonify({'citation': citation})",
Expand Down
2 changes: 1 addition & 1 deletion functional_tests/test_agents_catalog_feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def test_agents_catalog_routes_and_navigation():
sidebar = read_repo_file("application/single_app/templates/_sidebar_nav.html")
short_sidebar = read_repo_file("application/single_app/templates/_sidebar_short_nav.html")

assert_contains(app_route, "@app.route('/agents'", "Agents page route")
assert_contains(app_route, "@bp.route('/agents'", "Agents page route")
assert_contains(app_route, "@swagger_route(security=get_auth_security())", "Agents route swagger security")
assert_contains(app_route, "@login_required", "Agents route login guard")
assert_contains(app_route, "@user_required", "Agents route user guard")
Expand Down
2 changes: 1 addition & 1 deletion functional_tests/test_ai_search_index_management_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ def test_index_creation_endpoint():

# Check for index creation endpoint
required_patterns = [
"@app.route('/api/admin/settings/create_index', methods=['POST'])",
"@bp.route('/api/admin/settings/create_index', methods=['POST'])",
"def create_index():",
"from azure.search.documents.indexes.models import SearchIndex",
"index = SearchIndex.deserialize(index_definition)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,8 @@ def test_chat_document_action_stream_uses_workflow_executor():
"""Validate the chat Analyze/Compare stream path still uses workflow execution."""
route_text = CHAT_ROUTE.read_text(encoding="utf-8")
required_markers = [
"@app.route('/api/chat/document-action/stream', methods=['POST'])",
"@app.route('/api/chat/analyze/stream', methods=['POST'])",
"@bp.route('/api/chat/document-action/stream', methods=['POST'])",
"@bp.route('/api/chat/analyze/stream', methods=['POST'])",
"from functions_workflow_runner import _execute_document_action_workflow",
"execution_result = _execute_document_action_workflow(",
]
Expand Down
2 changes: 1 addition & 1 deletion functional_tests/test_chat_layered_message_masking.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,7 +298,7 @@ def test_frontend_and_routes_use_layered_masking_contract() -> None:
assert_contains(
collaboration_route_source,
[
"@app.route('/api/collaboration/conversations/<conversation_id>/messages/<message_id>/mask', methods=['POST'])",
"@bp.route('/api/collaboration/conversations/<conversation_id>/messages/<message_id>/mask', methods=['POST'])",
"_assert_user_can_mask_collaboration_message(current_user['user_id'], message_doc)",
"_sync_collaboration_mask_metadata_to_source(message_doc)",
"'collaboration.message.masked'",
Expand Down
4 changes: 2 additions & 2 deletions functional_tests/test_chat_retry_thought_tracker_init_fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def test_retry_and_edit_paths_initialize_thought_tracker_before_content_safety()
print('🔍 Testing retry/edit thought-tracker initialization...')

route_source = read_file_text(ROUTE_FILE)
chat_route_marker = "@app.route('/api/chat', methods=['POST'])"
chat_stream_marker = "@app.route('/api/chat/stream', methods=['POST'])"
chat_route_marker = "@bp.route('/api/chat', methods=['POST'])"
chat_stream_marker = "@bp.route('/api/chat/stream', methods=['POST'])"
chat_route_index = route_source.find(chat_route_marker)
chat_stream_index = route_source.find(chat_stream_marker)

Expand Down
4 changes: 2 additions & 2 deletions functional_tests/test_chat_stream_heartbeat_reattach.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ def test_chat_stream_heartbeat_and_reattach() -> None:
assert_contains(ROUTE_FILE, "yield ': keep-alive\\n\\n'")
assert_contains(ROUTE_FILE, "class ActiveConversationStreamSession:")
assert_contains(ROUTE_FILE, "CHAT_STREAM_REGISTRY = ActiveConversationStreamRegistry()")
assert_contains(ROUTE_FILE, "@app.route('/api/chat/stream/status/<conversation_id>', methods=['GET'])")
assert_contains(ROUTE_FILE, "@app.route('/api/chat/stream/reattach/<conversation_id>', methods=['GET'])")
assert_contains(ROUTE_FILE, "@bp.route('/api/chat/stream/status/<conversation_id>', methods=['GET'])")
assert_contains(ROUTE_FILE, "@bp.route('/api/chat/stream/reattach/<conversation_id>', methods=['GET'])")
assert_contains(ROUTE_FILE, "stream_with_context(stream_session.iter_events())")
assert_contains(ROUTE_FILE, "import app_settings_cache")
assert_contains(ROUTE_FILE, "app_settings_cache.initialize_stream_session_cache(")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def test_chat_stream_lifecycle_observability() -> None:
assert_contains(ROUTE_FILE, "def mark_reattached(self):")
assert_contains(ROUTE_FILE, "self._stream_session.note_keepalive(source='bridge')")
assert_contains(ROUTE_FILE, "self.note_keepalive(source='session')")
assert_contains(ROUTE_FILE, "@app.route('/api/chat/stream/client-event', methods=['POST'])")
assert_contains(ROUTE_FILE, "@bp.route('/api/chat/stream/client-event', methods=['POST'])")
assert_contains(ROUTE_FILE, "def chat_stream_client_event_api():")
assert_contains(ROUTE_FILE, "stream_status = stream_session.get_status_snapshot() if stream_session else _build_stream_status_payload(None)")
assert_contains(ROUTE_FILE, "stream_session.mark_reattached()")
Expand Down
Loading
Loading