From 2241d3dd13c99ebf9abd73de0f908b9c5d8b6bb5 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Tue, 8 Sep 2026 16:45:26 -0400 Subject: [PATCH] Fix React v2 group participant invitations Port source-aware group conversation conversion and resolve participant targets from primary group context. Preserve shared history, group-member restrictions, and repeat-invite routing. Refs #1472. Ports the backend fix from #1473. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_collaboration.py | 32 +- .../src/components/chat/ParticipantsPanel.tsx | 41 +- .../v2_ui/src/lib/conversationBadges.ts | 2 +- application/v2_ui/src/lib/sharing.ts | 26 +- ...UP_COLLABORATION_MEMBER_INVITE_FIX_PLAN.md | 13 +- .../GROUP_COLLABORATION_SOURCE_STORAGE_FIX.md | 232 +++++ ..._group_collaboration_source_storage_fix.py | 902 ++++++++++++++++++ .../test_v2_shared_conversation_logic.mjs | 82 +- .../fixtures/orchestration/harness_entry.tsx | 10 +- ui_tests/test_v2_group_participant_invites.py | 338 +++++++ 11 files changed, 1647 insertions(+), 33 deletions(-) create mode 100644 docs/explanation/fixes/GROUP_COLLABORATION_SOURCE_STORAGE_FIX.md create mode 100644 functional_tests/test_group_collaboration_source_storage_fix.py create mode 100644 ui_tests/test_v2_group_participant_invites.py diff --git a/application/single_app/config.py b/application/single_app/config.py index 40bd3cf01..32f7b3c4a 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -97,7 +97,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.105" +VERSION = "0.261.106" IS_DEVELOPMENT = is_development_env_enabled() SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py index 9b1ec849e..470f1b36f 100644 --- a/application/single_app/functions_collaboration.py +++ b/application/single_app/functions_collaboration.py @@ -1154,10 +1154,23 @@ def _copy_legacy_group_messages_to_collaboration(source_conversation_id, collabo def ensure_group_collaboration_for_legacy_conversation(source_conversation_id, owner_user, invited_participants=None): - source_conversation_doc = cosmos_group_conversations_container.read_item( - item=source_conversation_id, - partition_key=source_conversation_id, - ) + source_container = cosmos_group_conversations_container + copy_source_messages = _copy_legacy_group_messages_to_collaboration + source_link_field = 'legacy_source_conversation_id' + try: + source_conversation_doc = source_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) + except CosmosResourceNotFoundError: + # Group context can classify a conversation without moving its backing stores. + source_container = cosmos_conversations_container + copy_source_messages = _copy_legacy_personal_messages_to_collaboration + source_link_field = 'source_conversation_id' + source_conversation_doc = source_container.read_item( + item=source_conversation_id, + partition_key=source_conversation_id, + ) owner_summary = owner_user or {} owner_user_id = str(owner_summary.get('user_id') or '').strip() if not owner_user_id: @@ -1236,8 +1249,9 @@ def ensure_group_collaboration_for_legacy_conversation(source_conversation_id, o ) collaboration_conversation_doc['strict'] = bool(source_conversation_doc.get('strict', False)) collaboration_conversation_doc['summary'] = source_conversation_doc.get('summary') - collaboration_conversation_doc['legacy_source_conversation_id'] = source_conversation_id - collaboration_conversation_doc['legacy_source_scope'] = 'group' + collaboration_conversation_doc[source_link_field] = source_conversation_id + if source_link_field == 'legacy_source_conversation_id': + collaboration_conversation_doc['legacy_source_scope'] = 'group' source_context = list(source_conversation_doc.get('context', []) or []) if source_context: @@ -1249,7 +1263,7 @@ def ensure_group_collaboration_for_legacy_conversation(source_conversation_id, o if source_locked_contexts: collaboration_conversation_doc['locked_contexts'] = source_locked_contexts - copied_messages = _copy_legacy_group_messages_to_collaboration( + copied_messages = copy_source_messages( source_conversation_id, collaboration_conversation_doc.get('id'), owner_summary, @@ -1270,7 +1284,9 @@ def ensure_group_collaboration_for_legacy_conversation(source_conversation_id, o source_conversation_doc['converted_to_collaboration_at'] = conversion_timestamp source_conversation_doc['is_hidden'] = True source_conversation_doc['last_updated'] = conversion_timestamp - cosmos_group_conversations_container.upsert_item(source_conversation_doc) + source_container.upsert_item(source_conversation_doc) + invalidate_conversation_cache_for_item(source_conversation_doc, reason="collaboration_source_converted") + invalidate_conversation_cache_for_item(collaboration_conversation_doc, reason="collaboration_converted") log_event( '[COLLABORATION] Converted group conversation into collaborative conversation', diff --git a/application/v2_ui/src/components/chat/ParticipantsPanel.tsx b/application/v2_ui/src/components/chat/ParticipantsPanel.tsx index 38489e50f..2b36f3855 100644 --- a/application/v2_ui/src/components/chat/ParticipantsPanel.tsx +++ b/application/v2_ui/src/components/chat/ParticipantsPanel.tsx @@ -32,8 +32,13 @@ import { useCollaborationStore, participantName } from '../../stores/collaborati import { useBootstrapStore } from '../../stores/bootstrapStore'; import { toast } from '../../stores/toastStore'; import { fetchCollaboratorSuggestions, fetchGroupMembers } from '../../lib/collaboration'; +import { panelTargetForConversation } from '../../lib/sharing'; import { GlassButton, GlassPanel, Skeleton } from '../ui/primitives'; -import type { CollaborationParticipant, CollaboratorSuggestion } from '../../lib/types'; +import { + GROUP_MULTI_USER_CHAT_TYPE, + type CollaborationParticipant, + type CollaboratorSuggestion, +} from '../../lib/types'; /** How long to wait after a keystroke before searching. */ const SEARCH_DEBOUNCE_MS = 250; @@ -273,7 +278,19 @@ export function ParticipantsPanel() { const canManageRoles = shared && Boolean(conversation?.can_manage_roles); const canDelete = shared && Boolean(conversation?.can_delete_conversation); const canLeave = shared && Boolean(conversation?.can_leave_conversation); - const groupId = panelTarget.groupId ?? (shared ? conversation?.group_id : null) ?? null; + const groupId = panelTarget.groupId + ?? (shared ? panelTargetForConversation(panelTarget.conversationId, conversation).groupId : null) + ?? null; + const sharedScope = shared ? conversation?.scope : null; + const isGroupScope = sharedScope !== null + && typeof sharedScope === 'object' + && 'type' in sharedScope + && sharedScope.type === 'group'; + const missingGroupContext = !groupId && ( + panelTarget.kind === 'group' + || (shared && conversation?.chat_type === GROUP_MULTI_USER_CHAT_TYPE) + || isGroupScope + ); const existingIds = new Set( participants.map((participant) => String(participant.user_id ?? '').trim()), @@ -463,12 +480,20 @@ export function ParticipantsPanel() { )} {canManageMembers ? ( - void invite(participant)} - busy={busy} - /> + missingGroupContext ? ( +

+ + This conversation's group could not be identified. Reload it + before adding people. +

+ ) : ( + void invite(participant)} + busy={busy} + /> + ) ) : ( shared && (

diff --git a/application/v2_ui/src/lib/conversationBadges.ts b/application/v2_ui/src/lib/conversationBadges.ts index fbd8bc2c9..2ee829af2 100644 --- a/application/v2_ui/src/lib/conversationBadges.ts +++ b/application/v2_ui/src/lib/conversationBadges.ts @@ -41,7 +41,7 @@ function contexts(metadata: BadgeSource | null | undefined): ContextEntry[] { } /** The context a conversation is primarily bound to, for a given scope. */ -function primaryContext( +export function primaryContext( metadata: BadgeSource | null | undefined, scope: 'group' | 'public', ): ContextEntry | undefined { diff --git a/application/v2_ui/src/lib/sharing.ts b/application/v2_ui/src/lib/sharing.ts index 6a1f383bb..4299cf6d5 100644 --- a/application/v2_ui/src/lib/sharing.ts +++ b/application/v2_ui/src/lib/sharing.ts @@ -11,6 +11,7 @@ // chat-collaboration.js. import { isCollaborative } from './types'; +import { primaryContext, resolveChatType } from './conversationBadges'; import type { Conversation, ConversationMetadata } from './types'; import type { ParticipantsPanelTarget } from '../stores/collaborationStore'; @@ -21,17 +22,16 @@ import type { ParticipantsPanelTarget } from '../stores/collaborationStore'; * so offering to share one would present an action with nothing behind it. */ const SHAREABLE_CHAT_TYPES = new Set([ - '', 'personal_single_user', 'personal_multi_user', + 'group', 'group-single-user', + 'group_single_user', 'group_multi_user', ]); function chatTypeOf(conversation: Conversation | ConversationMetadata | null | undefined): string { - return String(conversation?.chat_type ?? '') - .trim() - .toLowerCase(); + return resolveChatType(conversation).toLowerCase(); } /** Whether a Share action should be offered for this conversation at all. */ @@ -57,16 +57,22 @@ export function panelTargetForConversation( conversation: Conversation | ConversationMetadata | null | undefined, ): ParticipantsPanelTarget { const chatType = chatTypeOf(conversation); - const groupId = - (conversation?.group_id as string | undefined) ?? - (conversation?.scope as { group_id?: string } | undefined)?.group_id ?? - null; + const scope = conversation?.scope; + const scopeGroupId = + scope && typeof scope === 'object' && 'group_id' in scope ? scope.group_id : undefined; + // Regular-stored group chats carry their workspace in primary context, not scope. + const groupId = [ + conversation?.group_id, + scopeGroupId, + primaryContext(conversation, 'group')?.id, + ].find((value): value is string => typeof value === 'string' && value.trim().length > 0) + ?.trim() ?? null; if (isCollaborative(conversation)) { return { conversationId, kind: 'collaborative', - title: conversation?.title as string | undefined, + title: conversation?.title, groupId, }; } @@ -74,7 +80,7 @@ export function panelTargetForConversation( return { conversationId, kind: chatType.startsWith('group') ? 'group' : 'personal', - title: conversation?.title as string | undefined, + title: conversation?.title, groupId, }; } diff --git a/docs/explanation/features/GROUP_COLLABORATION_MEMBER_INVITE_FIX_PLAN.md b/docs/explanation/features/GROUP_COLLABORATION_MEMBER_INVITE_FIX_PLAN.md index 4383589e2..f3cfe06d7 100644 --- a/docs/explanation/features/GROUP_COLLABORATION_MEMBER_INVITE_FIX_PLAN.md +++ b/docs/explanation/features/GROUP_COLLABORATION_MEMBER_INVITE_FIX_PLAN.md @@ -2,9 +2,17 @@ Planning version: **0.250.062** -Implemented in version: **Not implemented - discovery and planning only** +Historical plan status: **Discovery and planning only; not implemented in full** -Related configuration version: `application/single_app/config.py` currently sets `VERSION = "0.250.062"`. +Related configuration version at planning time: `application/single_app/config.py` set `VERSION = "0.250.062"`. + +The backend source-storage mismatch described below is fixed for Development/v1 in +**0.261.024** under [#1472](https://github.com/microsoft/simplechat/issues/1472). +The separate React/v2 port and People-panel group-context handling are implemented +in **0.261.106**. +See [Group Collaboration Source Storage Fix](../fixes/GROUP_COLLABORATION_SOURCE_STORAGE_FIX.md) +for the implemented scope and coverage. Neither change implements this historical +plan's classic-UI wording, stale-DOM, or endpoint-selection proposals in full. ## Overview @@ -315,4 +323,3 @@ Cover: 1. Should `/from-group//members` delegate when `` is already a group collaborative conversation ID, or should it return a diagnostic error? 2. Should the UI show a one-line hint with the active group name in the participant picker? 3. Should group participant suggestions include pending group users, or only accepted/current group members? Current behavior should remain accepted/current members unless product requirements change. - diff --git a/docs/explanation/fixes/GROUP_COLLABORATION_SOURCE_STORAGE_FIX.md b/docs/explanation/fixes/GROUP_COLLABORATION_SOURCE_STORAGE_FIX.md new file mode 100644 index 000000000..d988d8360 --- /dev/null +++ b/docs/explanation/fixes/GROUP_COLLABORATION_SOURCE_STORAGE_FIX.md @@ -0,0 +1,232 @@ +# Group Collaboration Source Storage Fix (v0.261.106) + +Fixed in Development/v1 version: **0.261.024** + +Implemented in React/v2 version: **0.261.106** + +Related issue: [#1472](https://github.com/microsoft/simplechat/issues/1472). +The original Development fix was merged in +[#1473](https://github.com/microsoft/simplechat/pull/1473). + +The application patch version in `application\single_app\config.py` changed from +**0.261.023** to **0.261.024** on Development. The separate React-branch port +increments **0.261.105** to **0.261.106**, without merging unrelated Development +changes or changing the React branch's version sequence. + +## Issue + +Adding the first participant to an existing group-scoped single-user conversation +could return `404 Conversation not found`, even while its owner could still open +the conversation and read its history. + +Both interfaces send this invitation to the existing endpoint: + +```text +POST /api/collaboration/conversations/from-group//members +``` + +The failure occurred during source lookup, before the selected invitee was +evaluated. It did not necessarily mean the conversation had been deleted. + +## Root cause + +Normal conversation creation writes to `conversations`, with history in +`messages`. Group knowledge or a group agent can later establish primary group +context and classify the same record as `group-single-user` without moving either +record set. + +Group collaboration conversion assumed a different physical layout: +`group_conversations` and `group_messages`. The lookup, history copier, original +source update, and collaboration source links all followed that assumption. +Changing only the lookup would have left the copied history and later source +operations pointed at the wrong containers. + +## Implementation + +`ensure_group_collaboration_for_legacy_conversation()` now keeps the source +conversation container, existing message copier, and source-link field together +through conversion. + +Legacy group storage remains authoritative. Only a +`CosmosResourceNotFoundError` from that lookup permits a regular-storage lookup. +Authorization failures, throttling, service failures, and other exceptions do not +trigger another-store retry. If both containers lack the record, the endpoint +still returns the existing 404. + +| Original layout | History copier | Link on the collaborative conversation | +| --- | --- | --- | +| `conversations` / `messages` | `_copy_legacy_personal_messages_to_collaboration()` | `source_conversation_id` | +| `group_conversations` / `group_messages` | `_copy_legacy_group_messages_to_collaboration()` | `legacy_source_conversation_id`, with `legacy_source_scope = 'group'` | + +Both paths create a group collaboration. Reusing the regular-storage copier does +not change the conversation's group access rules. It preserves the distinction +between group workspace context and `source_conversation_scope = 'group'` message +provenance, which specifically selects the legacy group message store. + +Conversion preserves the existing title, context, tags, classification, +citation-tracking fields, strict mode, summary, and scope locks. The existing +copiers preserve supported message content and metadata, chronological ordering, +uploaded-content attribution, artifact filtering, and generated-image message +associations. Message counts and previews continue to derive from the copied +transcript. + +The original is hidden and back-linked in its actual container. Cache +invalidation runs after the hidden source and completed collaboration have been +persisted, preventing cached conversation lists from retaining the pre-conversion +state. + +### Source lifecycle + +For a regular-stored original, the existing shared AI source helper reuses +`source_conversation_id` and its history instead of creating an empty backing +conversation. Repeat invitations still reuse the collaboration after that helper +updates the backing record's chat type and kind. + +Legacy group originals retain their existing separate AI backing-source behavior. +The existing masking, deletion, archival, and retention helpers use the matching +source links and message provenance without new storage schemas or migrations. +Cleanup continues to enforce its source ownership and backward-link guards. + +### Preserved restrictions and API contract + +- Only the source conversation owner can convert an eligible group conversation. +- The owner must still hold a current allowed group role, and the group's status + must allow chat. Existing chat permissions for active, locked, and + upload-disabled groups remain unchanged; inactive groups remain rejected. +- Invitees must already be current group members. This flow does not add people + to a group workspace or allow arbitrary directory users into the conversation. +- Rejected ownership, group, or invitee requests do not copy history or write + conversion state. +- New conversions return 201 with `created: true`; subsequent invitations reuse + the collaboration and return 200 with `created: false`. Response fields and + creation/invitation events are unchanged. + +## React v2 participant flow + +Group-scoped records in regular storage can identify their group only through a +primary `context` entry. V2 previously read only top-level `group_id` and +`scope.group_id`, so its People panel could show the local-user search instead of +group members for these conversations. + +The sharing resolver now reuses the conversation badge helpers to interpret +primary context and legacy chat types. It resolves group identity from a +nonempty explicit group ID, then scope metadata, then primary group context. +Secondary group knowledge and the user's globally active group are not substitutes +for the conversation's own group identity. Personal and public conversations keep +their respective sharing behavior, including no sharing action for a public +conversation whose scope is known only through primary context. + +The People panel searches the identified group's current members. If a group +conversation has no usable group identity, it shows an explanatory error rather +than falling back to directory-wide candidates. A denied group-member search is +also surfaced without a directory fallback. + +After a successful first invitation, the existing store flow opens the new shared +conversation and loads its copied messages from the collaboration endpoint. +Reopening People uses the returned shared ID and the normal member endpoint for +subsequent invitations, rather than converting the original again. A failed +invitation leaves the original panel target intact so the user can retry. + +## Files changed + +- `application\single_app\functions_collaboration.py`: paired source selection, + existing copier/link selection, original-container update, and final cache + invalidation. +- `application\single_app\config.py`: application patch version. +- `functional_tests\test_group_collaboration_source_storage_fix.py`: isolated + behavioral regressions using actual production helpers and the shared routes. +- `application\v2_ui\src\lib\conversationBadges.ts` and + `application\v2_ui\src\lib\sharing.ts`: reuse the primary-context/type resolver + for participant targets without inferring storage from workspace scope. +- `application\v2_ui\src\components\chat\ParticipantsPanel.tsx`: resolve the + loaded shared conversation's group and surface missing identity instead of + searching directory-wide. +- `functional_tests\test_v2_shared_conversation_logic.mjs`: group-context, + precedence, legacy-type, and unchanged personal/public sharing cases. +- `ui_tests\test_v2_group_participant_invites.py` and its existing browser + harness: real React invitations through the isolated Flask handlers. +- `docs\explanation\features\GROUP_COLLABORATION_MEMBER_INVITE_FIX_PLAN.md`: + distinguish this implemented backend fix from the historical UI proposals. + +The route module, personal conversion implementation, and downstream cleanup +helpers are unchanged. + +## Validation + +The new regression first reproduced the exact 404 through the actual v1 +conversion handler against a regular-stored group conversation before the +production fix. + +Run the focused behavioral coverage with: + +```powershell +python -m pytest -q .\functional_tests\test_group_collaboration_source_storage_fix.py +``` + +The regression executes the production conversion, participant normalization, +group role/status checks, source bridge, metadata synchronization, masking, and +cleanup helpers against partition-aware in-memory stores. Flask request-context +dispatch exercises the existing route without application startup, Azure clients, +or deployed services. + +Coverage includes both layouts, all existing allowed group roles and chat +statuses, empty and populated histories, repeated invitations, current membership +revalidation, personal conversion compatibility, lookup priority, no-mutation +rejections, service-error responses, and manual/retention/archive cleanup that +leaves unrelated records untouched. + +The original Development regression passed **18 tests and 106 subtests** under +pytest. Its combined conversion, participant, image-proposal, shared-AI, retention, +route-policy, and documentation run completed with **60 tests and 106 subtests +passing**, plus the pre-existing uploaded-image regression failure described below. + +### React-specific coverage + +The React browser suite bundles the shipped People panel, message list, sharing +resolver, and stores. Invitation writes dispatch through the real Flask conversion +and member handlers using the existing in-memory Cosmos harness. The browser reads +the resulting conversations and copied messages, not a prebuilt success response. +No application configuration or live Azure data is loaded. + +```powershell +node .\functional_tests\test_v2_shared_conversation_logic.mjs +python -m pytest -q .\ui_tests\test_v2_group_participant_invites.py +``` + +The browser cases cover both storage layouts, group-only candidates, shared-ID +handoff and subsequent invitations, retained transcript content, a visible 404 with +retry, missing group identity, denied group search, and ordinary personal sharing. +Before the frontend fix, the group-context browser case failed because the group +search was absent; six pure-logic assertions also exposed the scope-resolution +gaps. After the fix, all **51 shared-conversation runtime checks** and the combined +backend/v2/retention/browser run's **43 tests and 106 subtests** pass. + +| Scenario | Before | After | +| --- | --- | --- | +| First group invitation for a regular-stored source | 404 before copying history | 201, with preserved transcript and a linked, hidden regular source | +| Legacy group-store conversion | Supported | Remains supported with its existing source conventions | +| Another invitation after conversion | Reuse required | 200, same collaboration, no transcript copy | +| Missing source in both stores | 404 | Same 404 | +| Non-not-found source storage error | Must surface as an error | No cross-store fallback or false success | + +One related pre-existing regression, +`test_collaboration_legacy_message_conversion.py::test_uploaded_image_conversion_preserves_user_sender`, +already fails on the Development baseline: it expects the older uploaded-image +role/content representation rather than the model's current image representation. +That model and test are unchanged. The new conversion coverage checks preservation +of the current uploaded-image sender metadata, provenance, and associations. + +## Scope and impact + +Owners can share affected group-scoped conversations without moving stored data +or losing the original history. Existing group authorization and participant +restrictions remain in place. + +The original Development/v1 change remains a backend-only fix. The separate React +follow-up ports that backend behavior and adds the V2 participant context handling +and browser coverage described above. Neither change adds routes/settings, +deployment changes, or a data migration, and the React follow-up does not change +the classic UI. +The broader proposals in the +[historical group invitation plan](../features/GROUP_COLLABORATION_MEMBER_INVITE_FIX_PLAN.md) +are not represented as completed by this fix. diff --git a/functional_tests/test_group_collaboration_source_storage_fix.py b/functional_tests/test_group_collaboration_source_storage_fix.py new file mode 100644 index 000000000..4e84e4305 --- /dev/null +++ b/functional_tests/test_group_collaboration_source_storage_fix.py @@ -0,0 +1,902 @@ +# test_group_collaboration_source_storage_fix.py +#!/usr/bin/env python3 +""" +Functional regression for group conversation source storage. +Version: 0.261.106 +Implemented in: 0.261.024 +Ported to the React branch in: 0.261.106 +Related issue: microsoft/simplechat#1472 + +Exercise production conversion and route helpers against isolated Cosmos stores. +Group context must not imply group-container storage or weaken authorization. +No application config, Azure clients, or deployed services are initialized. +The route is shared by v1 and v2; React interaction coverage lives in ui_tests. +""" + +import ast +from copy import deepcopy +from datetime import datetime, timezone +import logging +from pathlib import Path +import runpy +from types import SimpleNamespace +from typing import Any, Dict, Iterable, List, Optional +import unittest + +from flask import Blueprint, Flask, jsonify, request + +from test_retention_policy_conversation_scope_coverage import ( + FakeContainer, + FakeCosmosResourceNotFoundError, + load_source_members, +) + + +APP_ROOT = Path(__file__).resolve().parents[1] / 'application' / 'single_app' +COLLABORATION_FILE = APP_ROOT / 'functions_collaboration.py' +ROUTE_FILE = APP_ROOT / 'route_backend_collaboration.py' +SOURCE_ID = 'source-conversation' +GROUP_ID = 'group-1' +OWNER = { + 'user_id': 'conversation-owner', + 'display_name': 'Conversation Owner', + 'email': 'owner@example.com', +} +INVITEE = { + 'user_id': 'group-member', + 'display_name': 'Group Member', + 'email': 'member@example.com', +} +SECOND_INVITEE = { + 'user_id': 'second-member', + 'display_name': 'Second Member', + 'email': 'second@example.com', +} + +# These modules are pure helpers; loading by path avoids application bootstrap. +MODEL_HELPERS = runpy.run_path(str(APP_ROOT / 'collaboration_models.py')) +MASK_HELPERS = runpy.run_path(str(APP_ROOT / 'functions_message_masking.py')) +ARTIFACT_HELPERS = load_source_members( + str(APP_ROOT / 'functions_message_artifacts.py'), + {'is_assistant_artifact_role', 'filter_assistant_artifact_items'}, + assignment_names={'ASSISTANT_ARTIFACT_ROLE', 'ASSISTANT_ARTIFACT_CHUNK_ROLE'}, + namespace={'Any': Any, 'Dict': Dict, 'List': List, 'Optional': Optional}, +) +COLLABORATION_FUNCTIONS = { + '_copy_citation_tracking_conversation_fields', + 'is_collaboration_conversation', + 'is_personal_collaboration_conversation', + 'is_group_collaboration_conversation', + 'get_collaboration_visibility_mode', + 'is_invited_group_collaboration_conversation', + 'is_explicit_membership_collaboration', + 'get_collaboration_conversation', + 'get_collaboration_user_state', + 'get_collaboration_user_state_or_none', + 'get_personal_collaboration_participant', + 'get_personal_collaboration_role', + '_build_group_member_lookup', + '_normalize_group_conversation_participants', + 'ensure_collaboration_user_state_for_participant', + '_bootstrap_collaboration_user_state_from_participant', + 'serialize_collaboration_conversation', + 'get_personal_collaboration_conversation_by_source_conversation', + '_is_eligible_legacy_personal_conversation', + '_copy_legacy_personal_messages_to_collaboration', + 'ensure_personal_collaboration_for_legacy_conversation', + '_is_eligible_legacy_group_conversation', + '_copy_legacy_group_messages_to_collaboration', + 'ensure_group_collaboration_for_legacy_conversation', + 'create_personal_collaboration_conversation_record', + 'create_group_collaboration_conversation_record', + 'assert_user_can_view_collaboration_conversation', + 'invite_personal_collaboration_participants', + 'sync_collaboration_conversation_metadata_from_source', + 'ensure_collaboration_source_conversation', + '_archive_collaboration_item', + '_delete_item_if_present', + '_collaboration_retention_identity', + '_read_collaboration_conversation_for_retention', + '_cleanup_collaboration_thoughts', + '_cleanup_linked_collaboration_source', + '_delete_collaboration_conversation_records', + 'delete_collaboration_conversation_for_retention', + 'delete_personal_collaboration_conversation', +} + + +class StorageFailure(RuntimeError): + """A service failure that must not be mistaken for a missing document.""" + + +class TrackingContainer(FakeContainer): + """Reuse the retention fake with partition checks and observable operations.""" + + def __init__(self, partition_field): + super().__init__() + self.partition_field = partition_field + self.calls = [] + self.failures = {} + + def _record(self, operation, **details): + self.calls.append((operation, deepcopy(details))) + if operation in self.failures: + raise self.failures[operation] + + def read_item(self, item=None, partition_key=None): + self._record('read', item=item, partition_key=partition_key) + result = super().read_item(item=item, partition_key=partition_key) + assert result[self.partition_field] == partition_key + return result + + def query_items(self, query=None, parameters=None, partition_key=None, + enable_cross_partition_query=False): + self._record('query', query=query, parameters=parameters, partition_key=partition_key) + assert partition_key is not None or enable_cross_partition_query + results = super().query_items(query=query, parameters=parameters) + for parameter in parameters or []: + field = parameter['name'].removeprefix('@') + if field in {'conversation_kind', 'chat_type', 'source_conversation_id'}: + results = [item for item in results if item.get(field) == parameter['value']] + if partition_key is not None: + results = [item for item in results if item[self.partition_field] == partition_key] + if 'ORDER BY c.timestamp ASC' in (query or ''): + results.sort(key=lambda item: item['timestamp']) + return results + + def upsert_item(self, item): + self._record('upsert', item=item) + assert item.get(self.partition_field) + return super().upsert_item(item) + + def delete_item(self, item=None, partition_key=None): + self._record('delete', item=item, partition_key=partition_key) + if item in self.items: + assert self.items[item][self.partition_field] == partition_key + return super().delete_item(item=item, partition_key=partition_key) + + +def source_fixture(): + primary_context = { + 'type': 'primary', 'scope': 'group', 'id': GROUP_ID, 'name': 'Operations', + } + return { + 'id': SOURCE_ID, + 'user_id': OWNER['user_id'], + 'chat_type': 'group-single-user', + 'title': 'Group agent investigation', + 'context': [primary_context, {'type': 'secondary', 'scope': 'Model', 'id': 'N/A'}], + 'tags': [{'category': 'document', 'document_id': 'document-1', 'value': 'Runbook'}], + 'classification': ['Internal'], + 'used_documents_tracking_version': 1, + 'legacy_used_documents': [{'document_id': 'older-document'}], + 'used_documents': [{'document_id': 'document-1', 'group_id': GROUP_ID}], + 'strict': True, + 'summary': 'Existing incident summary', + 'scope_locked': True, + 'locked_contexts': [deepcopy(primary_context)], + 'is_hidden': False, + 'last_updated': '2026-09-01T10:00:00+00:00', + } + + +def history_fixture(): + messages = [ + {'id': 'user-message', 'role': 'user', 'content': 'Review the group runbook.'}, + { + 'id': 'assistant-message', 'role': 'assistant', 'content': 'Runbook response.', + 'agent_display_name': 'Group Agent', 'model_deployment_name': 'test-model', + 'hybrid_citations': [{'document_id': 'document-1', 'chunk_id': 'chunk-1'}], + 'citation_tracking_version': 1, + 'cited_hybrid_citations': [{'document_id': 'document-1', 'chunk_id': 'chunk-1'}], + }, + { + 'id': 'uploaded-file', 'role': 'file', 'content': 'runbook contents', + 'filename': 'runbook.txt', 'extracted_text': 'runbook contents', + 'workspace_document_id': 'document-1', + 'metadata': {'is_user_upload': True, 'user_info': deepcopy(OWNER)}, + }, + { + 'id': 'uploaded-image', 'role': 'image', 'content': '/api/image/uploaded-image', + 'filename': 'diagram.png', 'vision_analysis': 'Architecture diagram', + 'metadata': {'is_user_upload': True, 'user_info': deepcopy(OWNER)}, + }, + { + 'id': 'generated-image', 'role': 'image', 'content': '/api/image/generated-image', + 'metadata': {'image_proposal': { + 'visualId': 'visual-1', 'source_assistant_message_id': 'assistant-message', + }}, + }, + {'id': 'artifact', 'role': 'assistant_artifact', 'content': 'artifact payload'}, + {'id': 'artifact-chunk', 'role': 'assistant_artifact_chunk', 'content': 'chunk payload'}, + ] + for index, message in enumerate(messages): + message['conversation_id'] = SOURCE_ID + message['timestamp'] = f'2026-09-01T10:0{index}:00+00:00' + return list(reversed(messages)) + + +class ConversionHarness: + """Load actual helpers with in-memory storage and controlled external effects.""" + + def __init__(self, storage='regular', history=True): + self.storage = storage + self.user = deepcopy(OWNER) + self.feature_enabled = True + self.group = { + 'id': GROUP_ID, 'name': 'Operations', 'status': 'active', + 'owner': {'id': 'group-owner', 'displayName': 'Group Owner'}, + 'users': [ + {'userId': user['user_id'], 'displayName': user['display_name'], 'email': user['email']} + for user in (OWNER, INVITEE, SECOND_INVITEE) + ], + } + self.containers = {} + for name in ( + 'conversations', 'messages', 'group_conversations', 'group_messages', + 'collaboration_conversations', 'collaboration_messages', + 'collaboration_user_state', 'archived_conversations', 'archived_messages', + ): + partition_field = 'conversation_id' if name.endswith('messages') else 'id' + if name == 'collaboration_user_state': + partition_field = 'user_id' + self.containers[name] = TrackingContainer(partition_field) + prefix = 'group_' if storage == 'group' else '' + self.source = self.containers[f'{prefix}conversations'] + self.messages = self.containers[f'{prefix}messages'] + self.other_source = self.containers['conversations' if prefix else 'group_conversations'] + self.other_messages = self.containers['messages' if prefix else 'group_messages'] + self.source.items[SOURCE_ID] = source_fixture() + self.source.items['unrelated-source'] = {'id': 'unrelated-source', 'user_id': 'another-owner'} + self.messages.items = {item['id']: item for item in history_fixture()} if history else {} + self.messages.items['unrelated-message'] = { + 'id': 'unrelated-message', 'conversation_id': 'unrelated-source', + 'role': 'user', 'content': 'Unrelated', 'timestamp': '2026-09-01T00:00:00+00:00', + } + self.other_messages.items['wrong-store-message'] = { + 'id': 'wrong-store-message', 'conversation_id': SOURCE_ID, + 'role': 'user', 'content': 'Wrong store', 'timestamp': '2026-09-01T00:00:00+00:00', + } + self.invalidations = [] + self.events = [] + self.logs = [] + self.thought_cleanup = [] + self.blob_cleanup = [] + group_helpers = load_source_members( + str(APP_ROOT / 'functions_group.py'), + {'get_user_role_in_group', 'assert_group_role', 'check_group_status_allows_operation'}, + namespace={'Iterable': Iterable, 'find_group_by_id': self.find_group}, + ) + namespace = { + **MODEL_HELPERS, + **group_helpers, + 'deepcopy': deepcopy, + 'datetime': datetime, + 'timezone': timezone, + 'logging': logging, + 'CosmosResourceNotFoundError': FakeCosmosResourceNotFoundError, + 'filter_assistant_artifact_items': ARTIFACT_HELPERS['filter_assistant_artifact_items'], + 'invalidate_conversation_cache_for_item': self.invalidate, + 'log_event': lambda *args, **kwargs: self.logs.append((args, kwargs)), + 'log_conversation_archival': lambda **kwargs: None, + 'log_conversation_deletion': lambda **kwargs: None, + 'sync_chat_upload_workspace_document_sharing_for_collaboration': lambda item: None, + '_delete_blob_backed_collaboration_files': lambda messages: self.blob_cleanup.extend( + message['id'] for message in messages + ), + 'archive_thoughts_for_conversation': lambda conversation_id, user_id, **kwargs: ( + self.thought_cleanup.append(('archive', conversation_id, user_id)) + ), + 'delete_thoughts_for_conversation': lambda conversation_id, user_id, **kwargs: ( + self.thought_cleanup.append(('delete', conversation_id, user_id)) + ), + } + namespace.update({ + f'cosmos_{name}_container': container + for name, container in self.containers.items() + }) + self.namespace = load_source_members( + str(COLLABORATION_FILE), + COLLABORATION_FUNCTIONS, + assignment_names={'CITATION_TRACKING_CONVERSATION_FIELDS', 'PERSONAL_COLLABORATION_MANAGER_ROLES'}, + namespace=namespace, + ) + + def find_group(self, group_id): + return deepcopy(self.group) if self.group and self.group['id'] == group_id else None + + def invalidate(self, item, reason): + self.invalidations.append({ + 'item': deepcopy(item), + 'reason': reason, + 'persisted': { + name: deepcopy(container.items.get(item['id'])) + for name, container in self.containers.items() + if item['id'] in container.items + }, + }) + + def snapshot(self): + return {name: deepcopy(container.items) for name, container in self.containers.items()} + + def convert(self, participants=None): + return self.namespace['ensure_group_collaboration_for_legacy_conversation']( + SOURCE_ID, self.user, + invited_participants=[INVITEE] if participants is None else participants, + ) + + def build_route_app(self): + bp = Blueprint('collaboration_test', __name__) + namespace = load_source_members( + str(ROUTE_FILE), + { + 'get_user_state_or_none', '_build_collaboration_event', + '_normalize_participant_payload', '_require_collaboration_feature_enabled', + '_get_current_collaboration_user', '_sync_collaboration_mask_metadata_to_source', + }, + namespace={ + **self.namespace, + 'bp': bp, + 'jsonify': jsonify, + 'request': request, + 'get_settings': lambda: {'enable_collaborative_conversations': self.feature_enabled}, + 'get_current_user_info': lambda: self.user, + 'swagger_route': lambda **kwargs: (lambda function: function), + 'get_auth_security': lambda: {}, + 'login_required': lambda function: function, + 'user_required': lambda function: function, + 'copy_message_mask_metadata': MASK_HELPERS['copy_message_mask_metadata'], + 'COLLABORATION_EVENT_REGISTRY': SimpleNamespace( + publish=lambda conversation_id, event: self.events.append((conversation_id, deepcopy(event))), + ), + }, + ) + tree = ast.parse(ROUTE_FILE.read_text(encoding='utf-8'), filename=str(ROUTE_FILE)) + handler_names = { + 'convert_group_conversation_to_collaboration_api', + 'convert_personal_conversation_to_collaboration_api', + 'invite_collaboration_members_api', + } + handlers = [ + node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) + and node.name in handler_names + ] + assert len(handlers) == len(handler_names) + exec(compile(ast.Module(body=handlers, type_ignores=[]), str(ROUTE_FILE), 'exec'), namespace) + self.route_namespace = namespace + app = Flask(__name__) + app.config['TESTING'] = True + app.register_blueprint(bp) + return app + + def post(self, payload, path=None): + app = self.build_route_app() + path = path or f'/api/collaboration/conversations/from-group/{SOURCE_ID}/members' + with app.test_request_context(path, method='POST', json=payload): + return app.full_dispatch_request() + + +class GroupCollaborationSourceStorageTests(unittest.TestCase): + def test_v1_regular_store_invite_returns_created(self): + harness = ConversionHarness() + response = harness.post({'participants': [INVITEE]}) + self.assertEqual(response.status_code, 201, response.get_json()) + self.assertTrue(response.get_json()['created']) + + def test_conversion_preserves_history_metadata_and_storage_links(self): + for storage in ('regular', 'group'): + with self.subTest(storage=storage): + harness = ConversionHarness(storage) + original = deepcopy(harness.source.items[SOURCE_ID]) + before_messages = deepcopy(harness.messages.items) + other_before = deepcopy(harness.other_messages.items) + conversation, invite_states, created, source = harness.convert() + self.assertTrue(created) + self.assertEqual(conversation['chat_type'], 'group_multi_user') + self.assertEqual(conversation['scope']['group_id'], GROUP_ID) + self.assertEqual(conversation['scope']['visibility_mode'], 'invited_members') + self.assertEqual(conversation['pending_participant_ids'], [INVITEE['user_id']]) + self.assertEqual(invite_states[0]['membership_status'], 'pending') + for field in ( + 'title', 'context', 'tags', 'classification', 'used_documents_tracking_version', + 'legacy_used_documents', 'used_documents', 'strict', 'summary', + 'scope_locked', 'locked_contexts', + ): + self.assertEqual(conversation[field], original[field], field) + link_field = 'legacy_source_conversation_id' if storage == 'group' else 'source_conversation_id' + wrong_field = 'source_conversation_id' if storage == 'group' else 'legacy_source_conversation_id' + self.assertEqual(conversation[link_field], SOURCE_ID) + self.assertNotIn(wrong_field, conversation) + if storage == 'group': + self.assertEqual(conversation['legacy_source_scope'], 'group') + else: + self.assertNotIn('legacy_source_scope', conversation) + self.assertTrue(source['is_hidden']) + self.assertEqual(source['collaboration_conversation_id'], conversation['id']) + self.assertEqual(source['last_updated'], source['converted_to_collaboration_at']) + self.assertTrue(harness.source.items[SOURCE_ID]['is_hidden']) + self.assertNotIn(SOURCE_ID, harness.other_source.items) + self.assertEqual(harness.messages.items, before_messages) + self.assertEqual(harness.other_messages.items, other_before) + self.assertFalse(harness.other_messages.calls) + + copied = list(harness.containers['collaboration_messages'].items.values()) + by_source = {message['metadata']['source_message_id']: message for message in copied} + self.assertEqual(list(by_source), [ + 'user-message', 'assistant-message', 'uploaded-file', 'uploaded-image', 'generated-image', + ]) + self.assertEqual(conversation['message_count'], len(copied)) + self.assertEqual(conversation['last_message_at'], copied[-1]['timestamp']) + self.assertEqual(conversation['updated_at'], copied[-1]['timestamp']) + self.assertEqual(conversation['last_message_preview'], copied[-1]['metadata']['last_message_preview']) + for source_id, message in by_source.items(): + self.assertEqual(message['timestamp'], before_messages[source_id]['timestamp']) + self.assertEqual(message['conversation_id'], conversation['id']) + self.assertEqual(message['metadata']['source_conversation_id'], SOURCE_ID) + self.assertEqual(message['metadata']['source_thought_user_id'], OWNER['user_id']) + self.assertEqual(message['metadata'].get('source_conversation_scope') == 'group', storage == 'group') + assistant = by_source['assistant-message'] + for field in ('hybrid_citations', 'cited_hybrid_citations', 'citation_tracking_version', 'model_deployment_name'): + self.assertEqual(assistant[field], before_messages['assistant-message'][field]) + self.assertEqual(assistant['metadata']['sender']['user_id'], 'assistant') + self.assertEqual(by_source['uploaded-file']['filename'], 'runbook.txt') + self.assertEqual(by_source['uploaded-file']['extracted_text'], 'runbook contents') + self.assertEqual(by_source['uploaded-file']['workspace_document_id'], 'document-1') + self.assertEqual(by_source['uploaded-image']['metadata']['sender']['user_id'], OWNER['user_id']) + self.assertTrue(by_source['uploaded-image']['metadata']['is_user_upload']) + proposal = by_source['generated-image']['metadata']['image_proposal'] + self.assertEqual(proposal['source_assistant_message_id'], assistant['id']) + self.assertEqual(proposal['legacy_source_assistant_message_id'], 'assistant-message') + + def test_conversion_invalidates_final_persisted_records(self): + for storage in ('regular', 'group'): + with self.subTest(storage=storage): + harness = ConversionHarness(storage) + conversation, _, _, _ = harness.convert() + source_events = [event for event in harness.invalidations if event['item']['id'] == SOURCE_ID] + self.assertTrue(source_events) + source_store = 'group_conversations' if storage == 'group' else 'conversations' + self.assertTrue(source_events[-1]['persisted'][source_store]['is_hidden']) + self.assertEqual( + source_events[-1]['persisted'][source_store]['collaboration_conversation_id'], + conversation['id'], + ) + collaboration_events = [ + event for event in harness.invalidations if event['item']['id'] == conversation['id'] + ] + self.assertEqual(collaboration_events[-1]['item']['message_count'], 5) + self.assertEqual(collaboration_events[-1]['persisted']['collaboration_conversations']['message_count'], 5) + + def test_supported_group_scopes_and_chat_allowed_statuses(self): + for storage in ('regular', 'group'): + for chat_type in ('group-single-user', 'group_single_user', 'group', ''): + for status in ('active', 'locked', 'upload_disabled'): + with self.subTest(storage=storage, chat_type=chat_type, status=status): + harness = ConversionHarness(storage, history=False) + harness.source.items[SOURCE_ID]['chat_type'] = chat_type + harness.group['status'] = status + if chat_type: + harness.source.items[SOURCE_ID]['group_id'] = GROUP_ID + harness.source.items[SOURCE_ID]['context'] = [] + conversation, _, created, _ = harness.convert() + self.assertTrue(created) + self.assertEqual(conversation['message_count'], 0) + self.assertEqual(conversation['scope']['group_id'], GROUP_ID) + + def test_current_group_roles_remain_allowed(self): + for storage in ('regular', 'group'): + for role in ('Owner', 'Admin', 'DocumentManager', 'User'): + with self.subTest(storage=storage, role=role): + harness = ConversionHarness(storage, history=False) + if role == 'Owner': + harness.group['owner']['id'] = OWNER['user_id'] + elif role == 'Admin': + harness.group['admins'] = [OWNER['user_id']] + elif role == 'DocumentManager': + harness.group['documentManagers'] = [OWNER['user_id']] + self.assertEqual( + harness.namespace['get_user_role_in_group'](harness.group, OWNER['user_id']), + role, + ) + self.assertTrue(harness.convert()[2]) + + def test_rejections_do_not_read_history_or_mutate_stores(self): + cases = { + 'non_owner': PermissionError, + 'no_identity': PermissionError, + 'personal_scope': PermissionError, + 'public_scope': PermissionError, + 'already_collaborative': PermissionError, + 'missing_group_context': LookupError, + 'missing_group': LookupError, + 'not_group_member': PermissionError, + 'inactive_group': PermissionError, + 'non_group_invitee': ValueError, + } + for storage in ('regular', 'group'): + for case, exception_type in cases.items(): + with self.subTest(storage=storage, case=case): + harness = ConversionHarness(storage) + source = harness.source.items[SOURCE_ID] + participants = [INVITEE] + if case == 'non_owner': + harness.user = {'user_id': 'another-user'} + elif case == 'no_identity': + harness.user = {} + elif case in ('personal_scope', 'public_scope'): + scope = case.removesuffix('_scope') + source['chat_type'] = 'personal_single_user' if scope == 'personal' else 'public' + source['context'] = [{'type': 'primary', 'scope': scope, 'id': 'other-scope'}] + elif case == 'already_collaborative': + source['conversation_kind'] = 'collaborative' + elif case == 'missing_group_context': + source['context'] = [] + elif case == 'missing_group': + harness.group = None + elif case == 'not_group_member': + harness.group['users'] = [ + user for user in harness.group['users'] if user['userId'] != OWNER['user_id'] + ] + elif case == 'inactive_group': + harness.group['status'] = 'inactive' + elif case == 'non_group_invitee': + participants.append({'user_id': 'outsider'}) + before = harness.snapshot() + with self.assertRaises(exception_type): + harness.convert(participants) + self.assertEqual(harness.snapshot(), before) + self.assertFalse(harness.messages.calls) + self.assertFalse(harness.other_messages.calls) + self.assertFalse(harness.invalidations) + + def test_legacy_store_precedence_never_falls_back_after_rejection(self): + for rejected in (False, True): + with self.subTest(rejected=rejected): + harness = ConversionHarness('group') + harness.other_source.items[SOURCE_ID] = source_fixture() + if rejected: + harness.source.items[SOURCE_ID]['user_id'] = 'another-owner' + before_regular = deepcopy(harness.other_source.items) + if rejected: + with self.assertRaises(PermissionError): + harness.convert() + else: + conversation, _, _, _ = harness.convert() + self.assertEqual(conversation['legacy_source_conversation_id'], SOURCE_ID) + self.assertEqual(harness.other_source.items, before_regular) + self.assertFalse(harness.other_source.calls) + self.assertFalse(harness.other_messages.calls) + + def test_source_lookup_failures_are_not_missing_data(self): + for store_name in ('group_conversations', 'conversations'): + for failure in (StorageFailure('service unavailable'), PermissionError('storage forbidden')): + with self.subTest(store=store_name, failure=type(failure).__name__): + harness = ConversionHarness() + harness.containers[store_name].failures['read'] = failure + before = harness.snapshot() + with self.assertRaises(type(failure)) as raised: + harness.convert() + self.assertIs(raised.exception, failure) + self.assertEqual(harness.snapshot(), before) + self.assertFalse(harness.messages.calls) + self.assertFalse(harness.other_messages.calls) + if store_name == 'group_conversations': + self.assertFalse(harness.source.calls) + + def test_missing_sources_return_the_existing_404(self): + harness = ConversionHarness() + del harness.source.items[SOURCE_ID] + before = harness.snapshot() + response = harness.post({'participants': [INVITEE]}) + self.assertEqual(response.status_code, 404) + self.assertEqual(response.get_json(), {'error': 'Conversation not found'}) + self.assertEqual(harness.snapshot(), before) + self.assertFalse(harness.events) + + def test_repeated_invites_reuse_history_after_ai_source_synchronization(self): + for storage in ('regular', 'group'): + with self.subTest(storage=storage): + harness = ConversionHarness(storage) + first = harness.post({'participants': [INVITEE]}) + self.assertEqual(first.status_code, 201, first.get_json()) + payload = first.get_json() + self.assertEqual(set(payload), { + 'conversation', 'invited_participants', 'created', 'source_conversation_id', + }) + self.assertEqual(payload['source_conversation_id'], SOURCE_ID) + self.assertEqual(payload['invited_participants'], [{ + **INVITEE, 'membership_status': 'pending', + }]) + conversation_id = payload['conversation']['id'] + copied_before = deepcopy(harness.containers['collaboration_messages'].items) + source_messages_before = deepcopy(harness.messages.items) + self.assertEqual( + [event['event_type'] for _, event in harness.events], + ['collaboration.created', 'collaboration.member.invited'], + ) + + conversation = harness.namespace['get_collaboration_conversation'](conversation_id) + backing, conversation = harness.namespace['ensure_collaboration_source_conversation']( + conversation, OWNER, + ) + self.assertEqual(conversation['source_conversation_id'], backing['id']) + self.assertEqual(backing['conversation_kind'], 'collaboration_source') + self.assertEqual(backing['chat_type'], 'group') + self.assertEqual(backing['collaboration_conversation_id'], conversation_id) + self.assertTrue(backing['is_hidden']) + if storage == 'regular': + self.assertEqual(backing['id'], SOURCE_ID) + else: + self.assertNotEqual(backing['id'], SOURCE_ID) + self.assertEqual(conversation['legacy_source_conversation_id'], SOURCE_ID) + self.assertEqual(harness.messages.items, source_messages_before) + + repeated = harness.post({'participant': INVITEE}) + self.assertEqual(repeated.status_code, 200, repeated.get_json()) + self.assertFalse(repeated.get_json()['created']) + self.assertEqual(repeated.get_json()['invited_participants'], []) + self.assertEqual(repeated.get_json()['conversation']['id'], conversation_id) + self.assertEqual(len(harness.events), 2) + + added = harness.post({'participants': [SECOND_INVITEE]}) + self.assertEqual(added.status_code, 200, added.get_json()) + self.assertFalse(added.get_json()['created']) + self.assertEqual(added.get_json()['conversation']['id'], conversation_id) + self.assertEqual(added.get_json()['conversation']['pending_invite_count'], 2) + self.assertEqual(added.get_json()['conversation']['message_count'], 5) + self.assertEqual(harness.events[-1][1]['event_type'], 'collaboration.member.invited') + self.assertEqual(len(harness.events), 3) + self.assertEqual(harness.containers['collaboration_messages'].items, copied_before) + self.assertEqual(len(harness.containers['collaboration_conversations'].items), 1) + self.assertEqual(sum(operation == 'query' for operation, _ in harness.messages.calls), 1) + self.assertFalse(harness.other_messages.calls) + for event_conversation_id, event in harness.events: + self.assertEqual(event_conversation_id, conversation_id) + self.assertEqual(event['payload']['source_conversation_id'], SOURCE_ID) + + def test_regular_source_metadata_remains_synchronized(self): + harness = ConversionHarness() + conversation, _, _, _ = harness.convert() + backing, conversation = harness.namespace['ensure_collaboration_source_conversation']( + conversation, OWNER, + ) + backing_again, _ = harness.namespace['ensure_collaboration_source_conversation']( + conversation, INVITEE, + ) + self.assertEqual(backing_again['id'], SOURCE_ID) + self.assertEqual(backing_again['user_id'], OWNER['user_id']) + self.assertEqual(len(harness.containers['conversations'].items), 2) + backing['summary'] = 'Updated shared summary' + backing['used_documents'].append({'document_id': 'new-document', 'group_id': GROUP_ID}) + backing['tags'].append({'category': 'document', 'document_id': 'new-document'}) + updated, changed = harness.namespace['sync_collaboration_conversation_metadata_from_source']( + conversation, backing, + ) + self.assertTrue(changed) + for field in ('summary', 'used_documents', 'tags', 'context', 'scope_locked', 'locked_contexts'): + self.assertEqual(updated[field], backing[field]) + self.assertEqual( + harness.containers['collaboration_conversations'].items[conversation['id']]['summary'], + backing['summary'], + ) + + def test_source_message_masking_uses_storage_not_group_context(self): + for storage in ('regular', 'group'): + with self.subTest(storage=storage): + harness = ConversionHarness(storage) + harness.convert() + message = next( + deepcopy(item) for item in harness.containers['collaboration_messages'].items.values() + if item['metadata']['source_message_id'] == 'assistant-message' + ) + harness.other_messages.items['assistant-message'] = deepcopy( + harness.messages.items['assistant-message'] + ) + other_before = deepcopy(harness.other_messages.items) + message['metadata'].update({ + 'masked': True, + 'masked_by_user_id': OWNER['user_id'], + 'masked_timestamp': '2026-09-08T19:00:00+00:00', + }) + harness.build_route_app() + harness.route_namespace['_sync_collaboration_mask_metadata_to_source'](message) + source_metadata = harness.messages.items['assistant-message']['metadata'] + self.assertTrue(source_metadata['masked']) + self.assertEqual(source_metadata['masked_by_user_id'], OWNER['user_id']) + self.assertEqual(harness.other_messages.items, other_before) + self.assertFalse(harness.other_messages.calls) + + def test_manual_and_retention_cleanup_follow_converted_source_links(self): + for storage in ('regular', 'group'): + for mode in ('manual', 'retention', 'archive'): + with self.subTest(storage=storage, mode=mode): + harness = ConversionHarness(storage) + conversation, _, _, _ = harness.convert() + backing, conversation = harness.namespace['ensure_collaboration_source_conversation']( + conversation, OWNER, + ) + decoy = { + 'id': SOURCE_ID, 'user_id': 'another-owner', + 'collaboration_conversation_id': 'unrelated-collaboration', + } + harness.other_source.items[SOURCE_ID] = deepcopy(decoy) + other_messages_before = deepcopy(harness.other_messages.items) + copied_ids = set(harness.containers['collaboration_messages'].items) + source_message_ids = set(harness.messages.items) - {'unrelated-message'} + if mode == 'manual': + harness.namespace['delete_personal_collaboration_conversation']( + conversation['id'], OWNER['user_id'], + ) + else: + result = harness.namespace['delete_collaboration_conversation_for_retention']( + conversation, workspace_type='group', archiving_enabled=mode == 'archive', + ) + self.assertEqual(result['id'], conversation['id']) + self.assertNotIn(SOURCE_ID, harness.source.items) + self.assertEqual(set(harness.source.items), {'unrelated-source'}) + self.assertEqual(set(harness.messages.items), {'unrelated-message'}) + self.assertEqual(harness.other_source.items[SOURCE_ID], decoy) + self.assertEqual(harness.other_messages.items, other_messages_before) + for name in ( + 'collaboration_conversations', 'collaboration_messages', 'collaboration_user_state', + ): + self.assertFalse(harness.containers[name].items, name) + if backing['id'] != SOURCE_ID: + self.assertNotIn(backing['id'], harness.containers['conversations'].items) + if mode == 'archive': + self.assertEqual( + set(harness.containers['archived_conversations'].items), + {conversation['id'], SOURCE_ID, backing['id']}, + ) + self.assertEqual( + set(harness.containers['archived_messages'].items), + copied_ids | source_message_ids, + ) + self.assertTrue(all( + item['archived_by_retention_policy'] + for item in harness.containers['archived_messages'].items.values() + )) + self.assertFalse(harness.blob_cleanup) + else: + self.assertFalse(harness.containers['archived_conversations'].items) + self.assertFalse(harness.containers['archived_messages'].items) + self.assertEqual(set(harness.blob_cleanup), copied_ids | source_message_ids) + thought_operation = 'archive' if mode == 'archive' else 'delete' + self.assertIn((thought_operation, SOURCE_ID, OWNER['user_id']), harness.thought_cleanup) + + def test_linked_source_cleanup_keeps_owner_and_backlink_guards(self): + for storage in ('regular', 'group'): + for mismatch in ('owner', 'backlink'): + with self.subTest(storage=storage, mismatch=mismatch): + harness = ConversionHarness(storage) + conversation, _, _, _ = harness.convert() + field = 'user_id' if mismatch == 'owner' else 'collaboration_conversation_id' + harness.source.items[SOURCE_ID][field] = 'unrelated' + before = harness.snapshot() + message_calls_before = deepcopy(harness.messages.calls) + result = harness.namespace['_cleanup_linked_collaboration_source']( + conversation, + 'legacy_source_conversation_id' if storage == 'group' else 'source_conversation_id', + harness.source, harness.messages, + archiving_enabled=False, retention_deletion=False, + expected_user_id=OWNER['user_id'], + ) + self.assertIsNone(result) + self.assertEqual(harness.snapshot(), before) + self.assertEqual(harness.messages.calls, message_calls_before) + + def test_existing_personal_conversion_is_unchanged(self): + harness = ConversionHarness() + harness.group = None + source = harness.source.items[SOURCE_ID] + source['chat_type'] = 'personal_single_user' + source['context'] = [{'type': 'primary', 'scope': 'personal', 'id': OWNER['user_id']}] + source['locked_contexts'] = deepcopy(source['context']) + convert = harness.namespace['ensure_personal_collaboration_for_legacy_conversation'] + conversation, _, created, source = convert( + SOURCE_ID, OWNER, invited_participants=[INVITEE], + ) + self.assertTrue(created) + self.assertEqual(conversation['chat_type'], 'personal_multi_user') + self.assertEqual(conversation['source_conversation_id'], SOURCE_ID) + self.assertNotIn('legacy_source_conversation_id', conversation) + self.assertTrue(source['is_hidden']) + copied_before = deepcopy(harness.containers['collaboration_messages'].items) + backing, conversation = harness.namespace['ensure_collaboration_source_conversation']( + conversation, OWNER, + ) + self.assertEqual(backing['id'], SOURCE_ID) + repeated, states, created, _ = convert( + SOURCE_ID, OWNER, invited_participants=[SECOND_INVITEE], + ) + self.assertFalse(created) + self.assertEqual(repeated['id'], conversation['id']) + self.assertEqual(states[0]['user_id'], SECOND_INVITEE['user_id']) + self.assertEqual(harness.containers['collaboration_messages'].items, copied_before) + self.assertFalse(harness.other_source.calls) + self.assertFalse(harness.other_messages.calls) + + def test_v1_route_preserves_rejection_statuses_without_mutation(self): + cases = { + 'no_identity': 401, 'feature_disabled': 403, 'non_owner': 403, + 'personal_scope': 403, 'missing_group_context': 400, + 'not_group_member': 403, 'inactive_group': 403, + 'non_group_invitee': 400, 'missing_participants': 400, + } + for storage in ('regular', 'group'): + for case, status in cases.items(): + with self.subTest(storage=storage, case=case): + harness = ConversionHarness(storage) + payload = {'participants': [INVITEE]} + if case == 'no_identity': + harness.user = None + elif case == 'feature_disabled': + harness.feature_enabled = False + elif case == 'non_owner': + harness.user = {'user_id': 'another-user'} + elif case == 'personal_scope': + harness.source.items[SOURCE_ID].update({'chat_type': 'personal_single_user', 'context': []}) + elif case == 'missing_group_context': + harness.source.items[SOURCE_ID]['context'] = [] + elif case == 'not_group_member': + harness.group['users'] = [] + elif case == 'inactive_group': + harness.group['status'] = 'inactive' + elif case == 'non_group_invitee': + payload['participants'].append({'user_id': 'outsider'}) + elif case == 'missing_participants': + payload = {} + before = harness.snapshot() + response = harness.post(payload) + self.assertEqual(response.status_code, status, response.get_json()) + self.assertEqual(harness.snapshot(), before) + self.assertFalse(harness.messages.calls) + self.assertFalse(harness.events) + + def test_repeated_invites_recheck_current_group_restrictions(self): + for storage in ('regular', 'group'): + for case in ('membership_removed', 'inactive', 'non_group_invitee'): + with self.subTest(storage=storage, case=case): + harness = ConversionHarness(storage) + harness.convert() + participants = [SECOND_INVITEE] + expected_status = 403 + if case == 'membership_removed': + harness.group['users'] = [ + user for user in harness.group['users'] if user['userId'] != OWNER['user_id'] + ] + elif case == 'inactive': + harness.group['status'] = 'inactive' + else: + participants.append({'user_id': 'outsider'}) + expected_status = 400 + before = harness.snapshot() + message_calls_before = deepcopy(harness.messages.calls) + response = harness.post({'participants': participants}) + self.assertEqual(response.status_code, expected_status, response.get_json()) + self.assertEqual(harness.snapshot(), before) + self.assertEqual(harness.messages.calls, message_calls_before) + self.assertFalse(harness.events) + + def test_v1_storage_failures_surface_as_safe_500_responses(self): + for storage in ('regular', 'group'): + for operation in ('read', 'query', 'upsert'): + with self.subTest(storage=storage, operation=operation): + harness = ConversionHarness(storage) + container = harness.messages if operation == 'query' else harness.source + container.failures[operation] = StorageFailure('internal service details') + response = harness.post({'participants': [INVITEE]}) + self.assertEqual(response.status_code, 500, response.get_json()) + self.assertEqual(response.get_json(), { + 'error': 'Failed to convert group conversation to collaborative conversation', + }) + self.assertFalse(harness.source.items[SOURCE_ID]['is_hidden']) + self.assertNotIn('collaboration_conversation_id', harness.source.items[SOURCE_ID]) + self.assertFalse(harness.other_messages.calls) + self.assertFalse(harness.events) + self.assertTrue(harness.logs) + + +if __name__ == '__main__': + unittest.main() diff --git a/functional_tests/test_v2_shared_conversation_logic.mjs b/functional_tests/test_v2_shared_conversation_logic.mjs index d56138be2..44090a512 100644 --- a/functional_tests/test_v2_shared_conversation_logic.mjs +++ b/functional_tests/test_v2_shared_conversation_logic.mjs @@ -1,8 +1,9 @@ // test_v2_shared_conversation_logic.mjs // // Runtime test for the pure logic behind V2 shared conversations. -// Version: 0.261.038 +// Version: 0.261.106 // Implemented in: 0.261.038 +// Group participant source-context regressions added in: 0.261.106 (Refs #1472, #1473). // // The companion test, test_v2_shared_conversations.py, asserts that the V2 modules are wired // to the right endpoints and that the actions with no collaboration counterpart are hidden. @@ -625,6 +626,71 @@ check('a group conversation is shared through the group conversion route', () => assert.equal(target.groupId, 'g1'); }); +check('a regular-stored group conversation finds invitees through its primary context', () => { + const target = panelTargetForConversation('regular-source', { + chat_type: 'group-single-user', + context: [ + { type: 'secondary', scope: 'group', id: 'another-group' }, + { type: 'primary', scope: 'group', id: 'g1', name: 'Operations' }, + ], + }); + assert.equal(target.kind, 'group'); + assert.equal(target.groupId, 'g1'); + assert.equal(target.conversationId, 'regular-source'); +}); + +check('older group conversations infer their sharing route from primary context', () => { + for (const chatType of [undefined, '', 'group', 'group_single_user']) { + const conversation = { + chat_type: chatType, + context: [{ type: 'primary', scope: 'group', id: 'g1' }], + }; + assert.equal(canShareConversation(conversation), true); + const target = panelTargetForConversation('c1', conversation); + assert.equal(target.kind, 'group'); + assert.equal(target.groupId, 'g1'); + } +}); + +check('explicit group identity takes precedence over a contextual fallback', () => { + const conversation = { + chat_type: 'group-single-user', + group_id: ' explicit-group ', + scope: { group_id: 'scope-group' }, + context: [{ type: 'primary', scope: 'group', id: 'context-group' }], + }; + assert.equal(panelTargetForConversation('c1', conversation).groupId, 'explicit-group'); + conversation.group_id = ''; + assert.equal(panelTargetForConversation('c1', conversation).groupId, 'scope-group'); + conversation.scope.group_id = ' '; + assert.equal(panelTargetForConversation('c1', conversation).groupId, 'context-group'); +}); + +check('secondary group knowledge does not turn a personal conversation into a group invite', () => { + const target = panelTargetForConversation('c1', { + chat_type: 'personal_single_user', + context: [ + { type: 'primary', scope: 'personal', id: 'u1' }, + { type: 'secondary', scope: 'group', id: 'g1' }, + ], + }); + assert.equal(target.kind, 'personal'); + assert.equal(target.groupId, null); +}); + +check('incomplete group metadata never supplies a fabricated group id', () => { + for (const scope of [undefined, null, false, 'group', { group_id: {} }]) { + const target = panelTargetForConversation('c1', { + chat_type: 'group-single-user', + group_id: [], + scope, + context: [null, {}, { type: 'secondary', scope: 'group', id: 'g1' }], + }); + assert.equal(target.kind, 'group'); + assert.equal(target.groupId, null); + } +}); + check('an already-shared conversation takes members directly', () => { // Converting a second time would create another shared conversation alongside the // first, which is the failure this distinction exists to prevent. @@ -636,12 +702,26 @@ check('an already-shared conversation takes members directly', () => { assert.equal(target.kind, 'collaborative'); }); +check('a shared group conversation keeps its member endpoint with contextual group identity', () => { + const target = panelTargetForConversation('shared-id', { + conversation_kind: 'collaborative', + chat_type: 'group_multi_user', + context: [{ type: 'primary', scope: 'group', id: 'g1' }], + }); + assert.equal(target.kind, 'collaborative'); + assert.equal(target.groupId, 'g1'); + assert.equal(target.conversationId, 'shared-id'); +}); + check('a public workspace conversation cannot be shared', () => { // There is no conversion route for one, so offering to share it would be an action with // nothing behind it. assert.equal(canShareConversation({ chat_type: 'public' }), false); assert.equal(canShareConversation({ chat_type: 'personal_single_user' }), true); assert.equal(canShareConversation({ chat_type: 'group_multi_user' }), true); + assert.equal(canShareConversation({ + context: [{ type: 'primary', scope: 'public', id: 'public-1' }], + }), false); assert.equal(canShareConversation(null), false); }); diff --git a/ui_tests/fixtures/orchestration/harness_entry.tsx b/ui_tests/fixtures/orchestration/harness_entry.tsx index bf9abc121..d62296134 100644 --- a/ui_tests/fixtures/orchestration/harness_entry.tsx +++ b/ui_tests/fixtures/orchestration/harness_entry.tsx @@ -22,6 +22,7 @@ import * as controller from '../../../application/v2_ui/src/lib/orchestrationCon import * as plan from '../../../application/v2_ui/src/lib/orchestrationPlan'; import * as orchestration from '../../../application/v2_ui/src/lib/orchestration'; import * as resume from '../../../application/v2_ui/src/lib/orchestrationResume'; +import * as sharing from '../../../application/v2_ui/src/lib/sharing'; import { OrchestrationPlanCard } from '../../../application/v2_ui/src/components/chat/OrchestrationPlanCard'; import { ElicitationCard } from '../../../application/v2_ui/src/components/chat/ElicitationCard'; @@ -32,6 +33,8 @@ import { OrchestrationPlanEditorHost } from '../../../application/v2_ui/src/comp import { MessageList } from '../../../application/v2_ui/src/components/chat/MessageList'; import { Composer } from '../../../application/v2_ui/src/components/chat/Composer'; import { DocumentExplorer } from '../../../application/v2_ui/src/components/documents/DocumentExplorer'; +import { ParticipantsPanel } from '../../../application/v2_ui/src/components/chat/ParticipantsPanel'; +import { Toaster } from '../../../application/v2_ui/src/components/ui/Toaster'; function PlanEditorExperience() { const conversationId = chatStore.useChatStore((state) => state.activeConversationId); @@ -101,7 +104,9 @@ type ComponentName = | 'ApprovalPreferenceWorkflow' | 'PlanEditorExperience' | 'OrchestrationPlanEditorHost' - | 'ContextWorkflow'; + | 'ContextWorkflow' + | 'ParticipantsPanel' + | 'Toaster'; // eslint-disable-next-line @typescript-eslint/no-explicit-any const components: Record ReactElement | null> = { @@ -117,6 +122,8 @@ const components: Record ReactElement | null> = { PlanEditorExperience, OrchestrationPlanEditorHost, ContextWorkflow, + ParticipantsPanel, + Toaster, }; const roots = new Map(); @@ -234,6 +241,7 @@ const harness = { plan, orchestration, resume, + sharing, components, }; diff --git a/ui_tests/test_v2_group_participant_invites.py b/ui_tests/test_v2_group_participant_invites.py new file mode 100644 index 000000000..c11b664d7 --- /dev/null +++ b/ui_tests/test_v2_group_participant_invites.py @@ -0,0 +1,338 @@ +# test_v2_group_participant_invites.py +""" +Browser regression for React v2 group participant invitations. +Version: 0.261.106 +Implemented in: 0.261.106 +Related issue and backend fix: microsoft/simplechat#1472, #1473. + +The real People panel, sharing resolver, chat/collaboration stores and message list +use the actual Flask invite handlers against partition-aware in-memory Cosmos +stores. Only browser transport and external services are controlled. The existing +connection fixture supports local Chromium or a configured Azure Playwright +workspace; neither an application deployment nor live application data is needed. + +Run: python .\\ui_tests\\test_v2_group_participant_invites.py +""" + +from copy import deepcopy +from pathlib import Path +import re +import sys +from urllib.parse import parse_qs, urlsplit + +import pytest +from playwright.sync_api import expect + +# Resolve the existing browser and isolated backend harnesses for standalone runs. +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures" / "orchestration")) +sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures")) +sys.path.insert(0, str(REPO_ROOT / "functional_tests")) +import harness_build as hb # noqa: E402 +from playwright_connection import connect_options # noqa: E402,F401 +from test_group_collaboration_source_storage_fix import ( # noqa: E402 + COLLABORATION_FILE, + GROUP_ID, + INVITEE, + OWNER, + SECOND_INVITEE, + SOURCE_ID, + ConversionHarness, + history_fixture, + load_source_members, +) + + +pytestmark = pytest.mark.ui +DIRECTORY_USER = { + "user_id": "directory-user", + "display_name": "Directory Colleague", + "email": "colleague@example.com", +} + + +class ParticipantApi: + """Dispatch invitation writes to the real routes and serve their stored results.""" + + def __init__(self, storage): + self.backend = ConversionHarness(storage, history=False) + self.initial_source = deepcopy(self.backend.source.items[SOURCE_ID]) + self.backend.messages.items.update({ + message["id"]: message + for message in history_fixture() + if message["role"] in ("user", "assistant") + }) + self.requests = [] + self.unexpected = [] + self.expected_errors = set() + self.fail_member_search = False + helpers = load_source_members( + str(COLLABORATION_FILE), + {"serialize_collaboration_message", "_get_collaboration_display_role"}, + namespace=self.backend.namespace, + ) + self.serialize_message = helpers["serialize_collaboration_message"] + + def conversations(self): + return [ + self.backend.namespace["serialize_collaboration_conversation"]( + conversation, + current_user_id=OWNER["user_id"], + ) + for conversation in self.backend.containers["collaboration_conversations"].items.values() + ] + + def handle(self, route): + request = route.request + url = urlsplit(request.url) + path = url.path + self.requests.append((request.method, path)) + + if request.method == "POST" and re.fullmatch( + r"/api/collaboration/conversations/(?:from-group/|from-personal/)?[^/]+/members", + path, + ): + response = self.backend.post(request.post_data_json, path) + if response.status_code >= 400: + self.expected_errors.add((path, response.status_code)) + route.fulfill(status=response.status_code, json=response.get_json()) + return + + if path == "/api/user/settings" and request.method == "POST": + route.fulfill(json={"message": "Settings saved"}) + return + + if request.method == "GET": + if path == f"/api/groups/{GROUP_ID}/members": + if self.fail_member_search: + self.expected_errors.add((path, 403)) + route.fulfill(status=403, json={"error": "Group membership is required."}) + else: + query = parse_qs(url.query).get("search", [""])[0].casefold() + route.fulfill(json=[ + member for member in self.backend.group["users"] + if query in f"{member['displayName']} {member['email']}".casefold() + ]) + return + if path == "/api/user/collaboration-suggestions": + route.fulfill(json={"results": [DIRECTORY_USER]}) + return + if path == "/api/conversations/feed": + route.fulfill(json={ + "success": True, + "conversations": self.conversations(), + "has_more": False, + "next_cursor": None, + }) + return + for conversation in self.conversations(): + base = f"/api/collaboration/conversations/{conversation['id']}" + if path == base: + route.fulfill(json={"conversation": conversation}) + return + if path == f"{base}/messages": + route.fulfill(json={"messages": [ + self.serialize_message(message) + for message in self.backend.containers["collaboration_messages"].items.values() + if message["conversation_id"] == conversation["id"] + ]}) + return + if path == f"{base}/events": + route.fulfill(content_type="text/event-stream", body=": connected\n\n") + return + + self.unexpected.append(f"{request.method} {path}") + route.fulfill(status=404, json={"error": "Unexpected test endpoint."}) + + +@pytest.fixture +def participant_page(page, request): + hb.ensure_bundle() + api = ParticipantApi(getattr(request, "param", "regular")) + errors = [] + + def handle(route): + path = urlsplit(route.request.url).path + if path == "/harness.html": + route.fulfill( + content_type="text/html", + body=(hb.HERE / "harness.html").read_text(encoding="utf-8"), + ) + elif path == "/harness.bundle.js": + route.fulfill( + content_type="application/javascript", + body=hb.BUNDLE.read_text(encoding="utf-8"), + ) + elif path == "/favicon.ico": + route.fulfill(status=204) + else: + api.handle(route) + + def record_console(message): + if message.type != "error": + return + path = urlsplit(message.location.get("url", "")).path + if message.text.startswith("Failed to load resource:") and any( + path == expected_path and f"status of {status}" in message.text + for expected_path, status in api.expected_errors + ): + return + errors.append(message.text) + + page.route("**/*", handle) + page.on("pageerror", lambda error: errors.append(str(error))) + page.on("console", record_console) + page.set_viewport_size({"width": 1440, "height": 900}) + page.goto("http://simplechat.test/harness.html") + page.wait_for_function('() => typeof window.OrchHarness === "object"') + try: + yield page, api + finally: + page.evaluate("""async () => { + const H = window.OrchHarness; + await H.stores.userSettings.useUserSettingsStore.getState().flush(); + await H.stores.chat.useChatStore.getState().selectConversation(null); + H.reset(); + }""") + assert not api.unexpected, f"Unexpected requests: {api.unexpected}" + assert not errors, f"Browser errors: {errors}" + + +def open_panel(page, conversation, *, mount=False): + page.evaluate( + """({ conversation, mount, owner }) => { + const H = window.OrchHarness; + if (mount) { + H.reset(); + H.stores.collaboration.useCollaborationStore.getState().reset(); + H.stores.bootstrap.useBootstrapStore.setState({ + data: { + features: { enable_collaborative_conversations: true }, + catalogs: { models: [], agents: [], prompts: [] }, + settings: {}, + user: { id: owner.user_id, display_name: owner.display_name }, + scope: { active_group_id: 'unrelated-active-group' }, + }, + }); + H.stores.chat.useChatStore.setState({ + activeConversationId: conversation.id, + activeConversationKind: 'personal', + conversations: [conversation], + metadata: { ...conversation, conversation_id: conversation.id }, + messages: [], + messagesLoading: false, + }); + H.mount('test-root', 'ParticipantsPanel', {}); + H.mount('mount-a', 'MessageList', {}); + H.mount('mount-b', 'Toaster', {}); + } + H.stores.collaboration.useCollaborationStore.getState().openPanel( + H.sharing.panelTargetForConversation(conversation.id, conversation), + ); + }""", + {"conversation": conversation, "mount": mount, "owner": OWNER}, + ) + + +def expect_shared_history(page, conversation_id): + page.wait_for_function( + """(id) => { + const state = window.OrchHarness.stores.chat.useChatStore.getState(); + return state.activeConversationId === id + && state.activeConversationKind === 'collaborative' + && state.messages.length === 2 + && state.messages.every(message => message.conversation_id === id); + }""", + arg=conversation_id, + ) + expect(page.get_by_text("Review the group runbook.", exact=True)).to_be_visible() + expect(page.get_by_text("Runbook response.", exact=True)).to_be_visible() + + +@pytest.mark.parametrize("participant_page", ["regular", "group"], indirect=True) +def test_group_invites_preserve_history_and_use_the_shared_id_after_conversion(participant_page): + page, api = participant_page + open_panel(page, api.initial_source, mount=True) + expect(page.get_by_placeholder("Search group members")).to_be_visible() + page.get_by_role("button", name=re.compile(INVITEE["display_name"])).click() + expect(page.get_by_role("status").filter(has_text="Group Member was invited.")).to_be_visible() + conversation = api.conversations()[0] + expect_shared_history(page, conversation["id"]) + assert api.backend.source.items[SOURCE_ID]["is_hidden"] + + open_panel(page, conversation) + expect(page.get_by_role("heading", name="People in this conversation")).to_be_visible() + page.get_by_role("button", name=re.compile(SECOND_INVITEE["display_name"])).click() + expect(page.get_by_role("status").filter(has_text="Second Member was invited.")).to_be_visible() + + invitation_paths = [ + path for method, path in api.requests + if method == "POST" and path.endswith("/members") + ] + assert invitation_paths == [ + f"/api/collaboration/conversations/from-group/{SOURCE_ID}/members", + f"/api/collaboration/conversations/{conversation['id']}/members", + ] + assert len(api.conversations()) == 1 + assert api.conversations()[0]["pending_invite_count"] == 2 + assert ("GET", "/api/user/collaboration-suggestions") not in api.requests + expect(page.get_by_role("alert")).to_have_count(0) + + +def test_invite_failure_keeps_the_original_target_and_allows_retry(participant_page): + page, api = participant_page + del api.backend.source.items[SOURCE_ID] + open_panel(page, api.initial_source, mount=True) + page.get_by_role("button", name=re.compile(INVITEE["display_name"])).click() + expect(page.get_by_role("alert")).to_have_text("Conversation not found") + expect(page.get_by_role("dialog", name="Conversation participants")).to_be_visible() + expect(page.get_by_role("status")).to_have_count(0) + assert not api.conversations() + assert page.evaluate( + "() => window.OrchHarness.stores.collaboration.useCollaborationStore.getState().panelTarget.conversationId" + ) == SOURCE_ID + + api.backend.source.items[SOURCE_ID] = deepcopy(api.initial_source) + page.get_by_role("button", name=re.compile(INVITEE["display_name"])).click() + expect(page.get_by_role("status").filter(has_text="Group Member was invited.")).to_be_visible() + expect_shared_history(page, api.conversations()[0]["id"]) + + +def test_missing_group_identity_does_not_fall_back_to_directory_search(participant_page): + page, api = participant_page + conversation = {**api.initial_source, "context": []} + open_panel(page, conversation, mount=True) + expect(page.get_by_role("alert")).to_contain_text("group could not be identified") + expect(page.get_by_role("textbox", name="Search for people to add")).to_have_count(0) + assert not api.requests + + +def test_group_search_denial_is_visible_without_directory_fallback(participant_page): + page, api = participant_page + api.fail_member_search = True + open_panel(page, api.initial_source, mount=True) + expect(page.get_by_text("Group membership is required.", exact=True)).to_be_visible() + assert ("GET", "/api/user/collaboration-suggestions") not in api.requests + assert not api.conversations() + + +def test_personal_invites_keep_the_personal_conversion_path(participant_page): + page, api = participant_page + source = api.backend.source.items[SOURCE_ID] + source.update({ + "chat_type": "personal_single_user", + "context": [{"type": "primary", "scope": "personal", "id": OWNER["user_id"]}], + }) + open_panel(page, source, mount=True) + expect(page.get_by_placeholder("Search people")).to_be_visible() + page.get_by_role("button", name=re.compile(DIRECTORY_USER["display_name"])).click() + expect(page.get_by_role("status").filter(has_text="Directory Colleague was invited.")).to_be_visible() + conversation = api.conversations()[0] + expect_shared_history(page, conversation["id"]) + assert conversation["chat_type"] == "personal_multi_user" + assert ("POST", f"/api/collaboration/conversations/from-personal/{SOURCE_ID}/members") in api.requests + assert not any(path.startswith("/api/groups/") for _, path in api.requests) + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"]))