From 206db9dbe04095cb53a9c1f53a5041568368a48b Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Mon, 19 Oct 2026 14:15:10 -0400 Subject: [PATCH] Fix private action credential recovery in shared chats Resolve shared and execution conversation contexts without weakening personal-agent ownership. Restore private credential prompts and idempotent shared-answer delivery, clarify reload failures, and add regression coverage and documentation for version 0.261.348. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/agent_delegation_runtime.py | 3 +- application/single_app/config.py | 2 +- ...unctions_action_credential_continuation.py | 4 +- ...nctions_action_credential_conversations.py | 108 +++++++ .../functions_action_credential_runtime.py | 69 ++++- .../functions_action_credentials.py | 25 +- .../single_app/functions_collaboration.py | 16 +- .../functions_orchestration_collaboration.py | 6 +- .../route_backend_action_credentials.py | 52 +++- .../single_app/route_backend_collaboration.py | 18 ++ .../components/chat/CredentialRequestCard.tsx | 28 +- .../v2_ui/src/components/chat/MessageList.tsx | 4 +- .../v2_ui/src/lib/actionCredentialEvents.ts | 5 + .../v2_ui/src/lib/actionCredentialTypes.ts | 3 + .../v2_ui/src/lib/actionCredentials.ts | 3 + application/v2_ui/src/stores/chatStore.ts | 5 +- .../features/PERSONAL_ACTION_CREDENTIALS.md | 48 ++- .../SHARED_CHAT_ACTION_CREDENTIALS_FIX.md | 82 +++++ docs/guides/personal-action-credentials.md | 38 ++- docs/reference/chat-controls.md | 7 + .../cosmos_response_boundaries.json | 210 +++++++------ ..._action_credentials_chat_reconstruction.py | 152 +++++++++- ...action_credentials_shared_conversations.py | 283 ++++++++++++++++++ ...test_action_credentials_sk_continuation.py | 19 +- .../test_action_personal_credentials.py | 8 +- .../test_v2_action_personal_credentials.mjs | 7 +- ui_tests/test_v2_action_credentials.py | 96 +++++- .../test_v2_action_credentials_backend.py | 14 +- 28 files changed, 1161 insertions(+), 154 deletions(-) create mode 100644 application/single_app/functions_action_credential_conversations.py create mode 100644 docs/explanation/fixes/SHARED_CHAT_ACTION_CREDENTIALS_FIX.md create mode 100644 functional_tests/test_action_credentials_shared_conversations.py diff --git a/application/single_app/agent_delegation_runtime.py b/application/single_app/agent_delegation_runtime.py index bbdaf0e85..614b9e922 100644 --- a/application/single_app/agent_delegation_runtime.py +++ b/application/single_app/agent_delegation_runtime.py @@ -563,7 +563,7 @@ def drain_completed_delegations(self): return {"citations": _delegation_records_citations(records), "usage": _delegation_records_usage(records)} async def _run(self, messages, *, streaming=False, **kwargs): - from functions_action_credentials import _continuation, credential_continuation + from functions_action_credentials import _continuation, capture_chat_origin, credential_continuation from functions_action_credential_continuation import invoke_credential_agent, stream_credential_agent if self.prevent_replay and self._invocations and self.budget.attempts: @@ -609,6 +609,7 @@ async def _run(self, messages, *, streaming=False, **kwargs): "mode": "chat", "checkpoint_id": self.credential_checkpoint_id, "agent_reference": deepcopy(self.reference), "construction_profile": getattr(selected, "construction_profile", "agent-core"), + **capture_chat_origin(self.identity.user_id, self.identity.conversation_id), } if streaming: with agent_execution(frame), credential_continuation(descriptor): diff --git a/application/single_app/config.py b/application/single_app/config.py index 163df3cd3..4d0b020d2 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.347" +VERSION = "0.261.348" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_action_credential_continuation.py b/application/single_app/functions_action_credential_continuation.py index 3fb962e59..b5108dfd4 100644 --- a/application/single_app/functions_action_credential_continuation.py +++ b/application/single_app/functions_action_credential_continuation.py @@ -20,7 +20,7 @@ from agent_execution_context import current_agent_execution from functions_action_manifest import get_action_origin import functions_action_credential_state as state -from functions_action_credentials import _continuation +from functions_action_credentials import _continuation, authorize_conversation _journal = ContextVar("action_credential_journal", default=None) @@ -61,6 +61,8 @@ async def _function_boundary(context, next): journal.deferred.add(_call_id.get()) raise journal.pending try: + if (_continuation.get() or {}).get("resume"): + authorize_conversation(journal.user_id, journal.conversation_id) await next(context) except Exception as error: pending = find_credential_stop(error) diff --git a/application/single_app/functions_action_credential_conversations.py b/application/single_app/functions_action_credential_conversations.py new file mode 100644 index 000000000..e33340c79 --- /dev/null +++ b/application/single_app/functions_action_credential_conversations.py @@ -0,0 +1,108 @@ +# functions_action_credential_conversations.py +"""Credential-specific conversation routing; storage and policies come from bootstrap.""" + +from azure.cosmos.exceptions import CosmosResourceNotFoundError + +from action_credential_contracts import ActionCredentialError +from cosmos_document_utils import normalize_cosmos_document + + +class CredentialConversations: + def __init__(self, *, personal_container, shared_container, message_container, + authorize_personal, authorize_shared, authorize_orchestration): + self.personal = personal_container + self.shared = shared_container + self.messages = message_container + self.authorize_personal = authorize_personal + self.authorize_shared = authorize_shared + self.authorize_orchestration = authorize_orchestration + + @staticmethod + def _read(container, item_id, partition): + try: + return normalize_cosmos_document(container.read_item(item=item_id, partition_key=partition)) + except CosmosResourceNotFoundError: + return None + + @staticmethod + def _linked(source, shared): + return bool( + source + and source.get("conversation_kind") == "collaboration_source" + and source.get("collaboration_conversation_id") == shared["id"] + and source.get("user_id") == shared.get("created_by_user_id") + and source.get("id") in {shared["id"], shared.get("source_conversation_id")} + ) + + def resolve(self, user_id, conversation_id): + if not isinstance(conversation_id, str) or not conversation_id.strip() or not user_id: + raise LookupError("Conversation not found.") + source = self._read(self.personal, conversation_id, conversation_id) + shared_id = (source or {}).get("collaboration_conversation_id") + if source is not None and not shared_id: + self.authorize_personal(user_id, conversation_id) + return source + shared_id = shared_id or conversation_id + shared = self._read(self.shared, shared_id, shared_id) + if shared is None or shared.get("status") != "active": + raise LookupError("Conversation not found.") + self.authorize_shared(user_id, shared) + if source is not None and not self._linked(source, shared): + raise LookupError("Conversation not found.") + execution_ids = [] + source_id = shared.get("source_conversation_id") + if source_id: + backing = source if source and source["id"] == source_id else self._read(self.personal, source_id, source_id) + if not self._linked(backing, shared): + raise LookupError("Conversation not found.") + execution_ids.append(source_id) + if shared.get("created_by_user_id") == user_id: + backing = source if source and source["id"] == shared_id else self._read(self.personal, shared_id, shared_id) + if backing is not None: + if not self._linked(backing, shared): + raise LookupError("Conversation not found.") + self.authorize_orchestration(shared, user_id) + execution_ids.append(shared_id) + return { + "id": conversation_id, + "credential_visible_conversation_id": shared_id, + "credential_conversation_kind": "collaborative", + "credential_execution_ids": tuple(dict.fromkeys(execution_ids)), + } + + def chat_origin(self, user_id, conversation_id, message_id): + context = self.resolve(user_id, conversation_id) + visible_id = context.get("credential_visible_conversation_id") + if not visible_id or not message_id or conversation_id == visible_id: + raise ActionCredentialError("credential_continuation_unavailable") + message = self._read(self.messages, message_id, visible_id) + metadata = (message or {}).get("metadata") or {} + if ( + not message or message.get("conversation_id") != visible_id + or (metadata.get("sender") or {}).get("user_id") != user_id + or metadata.get("source_conversation_id") != conversation_id + or message.get("message_kind") != "ai_request" + or message.get("is_deleted") or metadata.get("is_deleted") + ): + raise ActionCredentialError("credential_continuation_unavailable") + return {"visible_conversation_id": visible_id, "collaboration_message_id": message_id} + + def authorize_request(self, user_id, item): + context = self.resolve(user_id, item["conversation_id"]) + if context.get("credential_conversation_kind") != "collaborative": + return context + if item["conversation_id"] not in context["credential_execution_ids"]: + raise LookupError("Conversation not found.") + descriptor = item.get("continuation") or {} + if descriptor.get("mode") == "chat": + origin = self.chat_origin(user_id, item["conversation_id"], descriptor.get("collaboration_message_id")) + if origin["visible_conversation_id"] != descriptor.get("visible_conversation_id"): + raise ActionCredentialError("credential_continuation_unavailable") + else: + if descriptor.get("mode") != "orchestration" or item["conversation_id"] != context["credential_visible_conversation_id"]: + raise ActionCredentialError("credential_continuation_unavailable") + shared = self._read(self.shared, context["credential_visible_conversation_id"], context["credential_visible_conversation_id"]) + if shared is None: + raise LookupError("Conversation not found.") + self.authorize_orchestration(shared, user_id) + return context diff --git a/application/single_app/functions_action_credential_runtime.py b/application/single_app/functions_action_credential_runtime.py index 9be8e73e9..5a9495d55 100644 --- a/application/single_app/functions_action_credential_runtime.py +++ b/application/single_app/functions_action_credential_runtime.py @@ -2,6 +2,7 @@ """Web/scheduler composition of credential services after runtime initialization.""" from action_credential_contracts import ActionCredentialError +from functions_action_credential_conversations import CredentialConversations import functions_action_credentials as credentials import functions_action_credential_continuation as continuation @@ -16,6 +17,69 @@ def configure_action_credential_runtime(): import functions_workspace_identities as identities import route_backend_chats from content_screening import access + from flask import has_request_context, request + + def authorize_shared(user_id, shared): + # Collaboration initializes storage; only initialized runtime callbacks load it. + from functions_collaboration import assert_user_can_participate_in_collaboration_conversation + + return assert_user_can_participate_in_collaboration_conversation(user_id, shared, read_only=True) + + def authorize_orchestration(shared, user_id): + from functions_orchestration_collaboration import authorize_shared_orchestration + + return authorize_shared_orchestration(shared, user_id, read_only=True) + + from config import cosmos_collaboration_conversations_container, cosmos_collaboration_messages_container + + conversations = CredentialConversations( + personal_container=route_backend_chats.cosmos_conversations_container, + shared_container=cosmos_collaboration_conversations_container, + message_container=cosmos_collaboration_messages_container, + authorize_personal=route_backend_chats._authorize_personal_conversation_access, + authorize_shared=authorize_shared, authorize_orchestration=authorize_orchestration, + ) + + def capture_chat_origin(user_id, conversation_id): + data = request.get_json(silent=True) if has_request_context() else None + message_id = data.get("m365_collaboration_message_id") if isinstance(data, dict) else None + return conversations.chat_origin(user_id, conversation_id, message_id) if message_id else {} + + def publish_chat_result(user_id, item, saved): + context = conversations.authorize_request(user_id, item) + if context.get("credential_conversation_kind") != "collaborative": + return saved + from functions_collaboration import ( + create_collaboration_message_notifications, get_collaboration_conversation, + mirror_source_message_to_collaboration, publish_collaboration_event, + serialize_collaboration_conversation, serialize_collaboration_message, + ) + + visible_id = context["credential_visible_conversation_id"] + shared = get_collaboration_conversation(visible_id) + authorize_shared(user_id, shared) + question = conversations._read( + conversations.messages, item["continuation"]["collaboration_message_id"], visible_id, + ) + sender = (question["metadata"] or {})["sender"] + message, shared, _created = mirror_source_message_to_collaboration( + shared, saved, sender, reply_to_message_id=question["id"], + extra_metadata={"source_thought_user_id": user_id}, + message_id="shared_" + saved["id"], + ) + if not message: + raise ActionCredentialError("credential_publication_unavailable", 503) + authorize_shared(user_id, shared) + create_collaboration_message_notifications(shared, message) + serialized = serialize_collaboration_message(message) + publish_collaboration_event(visible_id, { + "conversation_id": visible_id, "event_type": "collaboration.message.created", + "payload": { + "conversation": serialize_collaboration_conversation(shared, current_user_id=user_id), + "message": serialized, + }, + }) + return serialized def resolve_manifest(user_id, action_ref): scope_type, scope_id, action_id = action_ref.split(":", 2) @@ -38,7 +102,7 @@ def update_identity(*args, **kwargs): credentials.configure_action_credential_services(credentials.CredentialServices( current_user_id=functions_authentication.get_current_user_id_or_none, - authorize_conversation=route_backend_chats._authorize_personal_conversation_access, + authorize_conversation=conversations.resolve, resolve_manifest=resolve_manifest, read_identity=identities.get_workspace_identity, list_identities=identities.list_workspace_identities, @@ -46,6 +110,9 @@ def update_identity(*args, **kwargs): read_identity_auth=identities.get_workspace_identity_auth, create_identity=identities.create_workspace_identity, update_identity=update_identity, + authorize_request_context=conversations.authorize_request, + capture_chat_origin=capture_chat_origin, + publish_chat_result=publish_chat_result, )) continuation.configure_action_credential_continuation_services(continuation.CredentialContinuationServices( collect_sources=access.current_request_source_provenance, diff --git a/application/single_app/functions_action_credentials.py b/application/single_app/functions_action_credentials.py index 3b4a5db77..67a3492df 100644 --- a/application/single_app/functions_action_credentials.py +++ b/application/single_app/functions_action_credentials.py @@ -34,6 +34,9 @@ class CredentialServices: read_identity_auth: Callable create_identity: Callable update_identity: Callable + authorize_request_context: Callable | None = None + capture_chat_origin: Callable | None = None + publish_chat_result: Callable | None = None def configure_action_credential_services(services): @@ -74,6 +77,20 @@ def authorize_conversation(user_id, conversation_id): return credential_services().authorize_conversation(user_id, conversation_id) +def capture_chat_origin(user_id, conversation_id): + callback = _services.capture_chat_origin if _services is not None else None + return callback(user_id, conversation_id) if callback else {} + + +def request_conversation(item): + callback = credential_services().authorize_request_context + return callback(item["user_id"], item) if callback else authorize_conversation(item["user_id"], item["conversation_id"]) + + +def pending_execution_ids(context, conversation_id): + return context.get("credential_execution_ids", (conversation_id,)) + + def authorized_manifest(user_id, action_ref): return credential_services().resolve_manifest(user_id, action_ref) @@ -106,18 +123,22 @@ def safe_identities(user_id, requirements): def safe_request(item, manifest): user_id = item["user_id"] + context = request_conversation(item) requirements = [ {"slot": slot, "auth_type": auth_type, "label": "Proxy sign-in" if slot == "proxy" else "YAMCS sign-in"} for slot, auth_type in item["requirements"].items() ] result = { - "id": item["id"], "etag": item["_etag"], "conversation_id": item["conversation_id"], + "id": item["id"], "etag": item["_etag"], + "conversation_id": context.get("credential_visible_conversation_id", item["conversation_id"]), "action_name": manifest.get("displayName") or manifest.get("name") or "YAMCS", "destination_label": destination_label(manifest), "reason": item["reason"], "requirements": requirements, "identities": safe_identities(user_id, requirements), "status": "pending" if item["status"] == "saving" else item["status"], } + if context.get("credential_conversation_kind") == "collaborative": + result["conversation_kind"] = "collaborative" if item["status"] == "saving": result["etag"] = item["submission_etag"] for key in ("run_id", "turn_id"): @@ -128,7 +149,7 @@ def safe_request(item, manifest): def authorize_request(user_id, request_id): item = state.read(user_id, request_id, record_type="credential_request") - authorize_conversation(user_id, item["conversation_id"]) + request_conversation(item) manifest = authorized_manifest(user_id, item["action_ref"]) if credential_fingerprint(manifest) != item["fingerprint"]: raise ActionCredentialError("action_destination_changed") diff --git a/application/single_app/functions_collaboration.py b/application/single_app/functions_collaboration.py index c31b55e4c..554d28eed 100644 --- a/application/single_app/functions_collaboration.py +++ b/application/single_app/functions_collaboration.py @@ -336,7 +336,9 @@ def ensure_collaboration_user_state_for_participant( return state_doc -def _bootstrap_collaboration_user_state_from_participant(conversation_doc, participant_record, invited_by_user_id=None): +def _bootstrap_collaboration_user_state_from_participant( + conversation_doc, participant_record, invited_by_user_id=None, *, persist=True, +): if not isinstance(participant_record, dict): return None @@ -350,7 +352,8 @@ def _bootstrap_collaboration_user_state_from_participant(conversation_doc, parti or (conversation_doc or {}).get('created_at') or utc_now_iso() ) - state_doc = ensure_collaboration_user_state_for_participant( + builder = ensure_collaboration_user_state_for_participant if persist else build_collaboration_user_state + state_doc = builder( conversation_doc, participant_record, role=participant_record.get('role') or MEMBERSHIP_ROLE_MEMBER, @@ -363,7 +366,8 @@ def _bootstrap_collaboration_user_state_from_participant(conversation_doc, parti state_doc['responded_at'] = participant_record.get('responded_at') if participant_record.get('removed_at'): state_doc['removed_at'] = participant_record.get('removed_at') - cosmos_collaboration_user_state_container.upsert_item(state_doc) + if persist: + cosmos_collaboration_user_state_container.upsert_item(state_doc) return state_doc @@ -1646,7 +1650,7 @@ def list_group_collaboration_conversations_for_user(user_id): return conversations -def assert_user_can_view_collaboration_conversation(user_id, conversation_doc, allow_pending=False): +def assert_user_can_view_collaboration_conversation(user_id, conversation_doc, allow_pending=False, *, read_only=False): if not is_collaboration_conversation(conversation_doc): raise LookupError('Collaboration conversation not found') @@ -1702,6 +1706,7 @@ def assert_user_can_view_collaboration_conversation(user_id, conversation_doc, a conversation_doc, participant, invited_by_user_id=conversation_doc.get('created_by_user_id'), + persist=not read_only, ) if user_state is None: @@ -1733,11 +1738,12 @@ def assert_user_can_view_collaboration_conversation(user_id, conversation_doc, a raise PermissionError('Unsupported collaboration conversation type') -def assert_user_can_participate_in_collaboration_conversation(user_id, conversation_doc): +def assert_user_can_participate_in_collaboration_conversation(user_id, conversation_doc, *, read_only=False): access_context = assert_user_can_view_collaboration_conversation( user_id, conversation_doc, allow_pending=False, + read_only=read_only, ) if is_group_collaboration_conversation(conversation_doc): diff --git a/application/single_app/functions_orchestration_collaboration.py b/application/single_app/functions_orchestration_collaboration.py index 1a6d4b456..8edb380e5 100644 --- a/application/single_app/functions_orchestration_collaboration.py +++ b/application/single_app/functions_orchestration_collaboration.py @@ -71,14 +71,16 @@ def shared_conversation(conversation_id): return None -def authorize_shared_orchestration(collaboration, user_id): +def authorize_shared_orchestration(collaboration, user_id, *, read_only=False): """Require a current participant who started the conversation; return the backing fields.""" from functions_collaboration import ( assert_user_can_participate_in_collaboration_conversation, is_group_collaboration_conversation, ) - assert_user_can_participate_in_collaboration_conversation(user_id, collaboration) + assert_user_can_participate_in_collaboration_conversation( + user_id, collaboration, **({"read_only": True} if read_only else {}), + ) if str(collaboration.get('created_by_user_id') or '').strip() != str(user_id or '').strip(): raise SharedOrchestrationError() return { diff --git a/application/single_app/route_backend_action_credentials.py b/application/single_app/route_backend_action_credentials.py index 78423a0b5..a2b88fe20 100644 --- a/application/single_app/route_backend_action_credentials.py +++ b/application/single_app/route_backend_action_credentials.py @@ -2,10 +2,10 @@ """Credential-only interaction API. General workspace authoring stays unchanged.""" from functools import wraps +import hashlib import hmac import logging import secrets -import uuid from datetime import datetime, timezone from flask import Response, jsonify, request, session, stream_with_context @@ -14,7 +14,8 @@ from functions_authentication import get_current_user_id, login_required, user_required from functions_appinsights import log_event, workflow_log_context from functions_action_credentials import ( - authorize_conversation, authorize_request, credential_continuation, safe_request, submit_credentials, + authorize_conversation, authorize_request, credential_continuation, credential_services, + pending_execution_ids, request_conversation, safe_request, submit_credentials, ) import functions_action_credential_state as state from swagger_wrapper import swagger_route, get_auth_security @@ -125,7 +126,7 @@ def _orchestration_continue(item, user_id): conversation_id, run_id = item["conversation_id"], item["continuation"]["run_id"] owned = claim_run_continuation( run_id, user_id, conversation_id, - authorize=lambda: authorize_conversation(user_id, conversation_id), + authorize=lambda: request_conversation(item), message_container=cosmos_messages_container, credential_request_id=item["id"], ) if owned is None: @@ -186,6 +187,7 @@ def _chat_continue(item, user_id): async def execute(): with identity.bridge(target), agent_execution(frame), credential_continuation(descriptor): try: + request_conversation(item) return await invoke_credential_agent(agent, None) finally: await close_agent_resources(kernel) @@ -196,26 +198,42 @@ def frames(): try: result = asyncio.run(execute()) stage = "credential_response_persistence" - authorize_conversation(user_id, item["conversation_id"]) + context = request_conversation(item) message = { - "id": uuid.uuid4().hex, "conversation_id": item["conversation_id"], "user_id": user_id, + "id": "credential_answer_" + hashlib.sha256(item["id"].encode()).hexdigest(), + "conversation_id": item["conversation_id"], "user_id": user_id, "role": "assistant", "content": str(result), "created_at": datetime.now(timezone.utc).isoformat(), "metadata": {"action_credential_continuation": True}, + "agent_name": target.get("name"), + "agent_display_name": target.get("display_name") or target.get("name"), } saved = _persist_screened_assistant(message, user_id, settings=settings) + publish = credential_services().publish_chat_result + if publish: + saved = publish(user_id, item, saved) stage = "credential_continuation_finalization" _finish_request(user_id, item["id"], item["continuation_claim"], "completed") yield serialize_sse({"content": saved["content"]}) yield serialize_sse({ - "done": True, "conversation_id": item["conversation_id"], "message_id": saved["id"], + "done": True, + "conversation_id": context.get("credential_visible_conversation_id", item["conversation_id"]), + "conversation_kind": context.get("credential_conversation_kind", "personal"), + "message_id": saved["id"], "reload_messages": True, }) except ActionCredentialsRequired as error: _finish_request(user_id, item["id"], item["continuation_claim"], "superseded") yield serialize_sse(error.event) except Exception as error: - _finish_request(user_id, item["id"], item["continuation_claim"], "failed") + status = "failed" + if descriptor.get("visible_conversation_id") and stage in { + "credential_response_persistence", "credential_continuation_finalization", + }: + # The journal is already complete: an explicit retry may deliver its + # saved response, but must not invoke the external operation again. + status = "ready" + _finish_request(user_id, item["id"], item["continuation_claim"], status) _log_continuation_failure(error, item, stage) yield serialize_sse({"error": "Saved work could not be continued. Refresh this conversation."}) @@ -231,14 +249,18 @@ def register_route_backend_action_credentials(bp): def action_credentials_pending(): user_id = get_current_user_id() conversation_id = request.args.get("conversation_id") - authorize_conversation(user_id, conversation_id) + context = authorize_conversation(user_id, conversation_id) requests = [] - for row in state.pending(user_id, conversation_id): - try: - item, manifest = authorize_request(user_id, row["id"]) - requests.append(safe_request(item, manifest)) - except (PermissionError, LookupError, ActionCredentialError): - continue + for execution_id in pending_execution_ids(context, conversation_id): + for row in state.pending(user_id, execution_id): + try: + item, manifest = authorize_request(user_id, row["id"]) + requests.append(safe_request(item, manifest)) + except (PermissionError, LookupError): + continue + except ActionCredentialError as error: + if error.status_code >= 500: + raise return jsonify({"requests": requests, "csrf_token": _csrf_token()}) @bp.route("/api/action-credentials/", methods=["GET"]) @@ -298,6 +320,6 @@ def action_credentials_cancel(request_id): request_cancellation( item["continuation"]["run_id"], user_id, item["conversation_id"], - lambda: authorize_conversation(user_id, item["conversation_id"]), + lambda: request_conversation(item), ) return jsonify({"credential_request": safe_request(item, manifest)}) diff --git a/application/single_app/route_backend_collaboration.py b/application/single_app/route_backend_collaboration.py index e59d61de0..63a8430a1 100644 --- a/application/single_app/route_backend_collaboration.py +++ b/application/single_app/route_backend_collaboration.py @@ -2973,6 +2973,24 @@ def transform_event_block(event_block): except json.JSONDecodeError: return normalized_event_block + '\n\n' + credential_event = stream_payload.get('type') == 'action_credentials_required' or any( + line.strip() == 'event: action_credentials_required' + for line in normalized_event_block.split('\n') + ) + if credential_event: + # This response belongs only to its requesting actor. + # Never publish a credential DTO to the shared event registry. + from functions_action_credentials import authorize_request, safe_request + + pending = stream_payload.get('credential_request') or {} + item, manifest = authorize_request(current_user['user_id'], pending.get('id')) + if ( + item['conversation_id'] != source_conversation_id + or item['continuation'].get('collaboration_message_id') != user_message_doc['id'] + ): + return collaboration_stream_error('This action sign-in request is no longer available.') + return f'data: {json.dumps({"type": "action_credentials_required", "credential_request": safe_request(item, manifest)})}\n\n' + ai_activity.step(describe_ai_activity_step(stream_payload)) if stream_payload.get('type') == 'm365_pending_action' or 'm365_pending_actions' in stream_payload: diff --git a/application/v2_ui/src/components/chat/CredentialRequestCard.tsx b/application/v2_ui/src/components/chat/CredentialRequestCard.tsx index 58903dbd3..0d9856669 100644 --- a/application/v2_ui/src/components/chat/CredentialRequestCard.tsx +++ b/application/v2_ui/src/components/chat/CredentialRequestCard.tsx @@ -11,6 +11,7 @@ import { cancelActionCredentials, clearCredentialSession, fetchActionCredentialR import { fetchOrchestrationRun, isOrchestrationRunWaiting } from '../../lib/orchestration'; import type { CredentialRequest, CredentialSlot, CredentialSubmission } from '../../lib/actionCredentialTypes'; import { ApiError } from '../../lib/apiClient'; +import type { ConversationKind } from '../../lib/endpoints'; const inputClass = 'w-full rounded-xl border border-edge bg-surface-1 px-3 py-2 text-sm text-text-1 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20'; const reasonMessages: Record = { @@ -21,19 +22,24 @@ const reasonMessages: Record = { identity_removed: 'The previously selected identity is no longer available. Choose another identity or save a new one.', }; -export function CredentialRequestCards({ conversationId }: { conversationId: string }) { +export function CredentialRequestCards({ conversationId, conversationKind = 'personal' }: { + conversationId: string; conversationKind?: ConversationKind | null; +}) { const userId = useBootstrapStore((state) => state.data?.user?.id); - return ; + if (!conversationKind) return null; + return ; } function CredentialRequestList({ conversationId }: { conversationId: string }) { const [requests, setRequests] = useState([]); const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); const refresh = useRef<() => void>(() => {}); useEffect(() => { clearCredentialSession(); const controller = new AbortController(); let eventSeen = false; + let inFlight = false; const accept = (credentialRequest: CredentialRequest) => { if (credentialRequest.conversation_id !== conversationId) return; setRequests((current) => [...current.filter((item) => item.id !== credentialRequest.id), credentialRequest]); @@ -50,6 +56,9 @@ function CredentialRequestList({ conversationId }: { conversationId: string }) { } }; const load = () => { + if (inFlight || controller.signal.aborted) return; + inFlight = true; + setLoading(true); eventSeen = false; void fetchCredentialRequests(conversationId, controller.signal).then((pending) => { if (controller.signal.aborted) return; @@ -63,7 +72,14 @@ function CredentialRequestList({ conversationId }: { conversationId: string }) { }).catch((failure: unknown) => { if (controller.signal.aborted) return; if (failure instanceof ApiError && [401, 403, 404].includes(failure.status)) setRequests([]); - setError('Pending action credentials could not be loaded.'); + setError(failure instanceof ApiError && failure.status === 401 + ? 'Your session has expired. Sign in again, then reopen this conversation.' + : failure instanceof ApiError && [403, 404].includes(failure.status) + ? 'Your access to this conversation could not be confirmed. Reopen it or ask its owner.' + : 'Unfinished action sign-in requests could not be checked. Try again.'); + }).finally(() => { + inFlight = false; + if (!controller.signal.aborted) setLoading(false); }); }; refresh.current = load; @@ -76,8 +92,10 @@ function CredentialRequestList({ conversationId }: { conversationId: string }) { }; }, [conversationId]); return
- {error ?
- {error} refresh.current()}>Reload requests + {error ?
+ {error} refresh.current()}> + {loading ? 'Checking requests...' : 'Reload requests'} +
: null} {requests.filter((item) => item.status === 'pending' || item.status === 'ready').map((item) => )} - {activeConversationId ? : null} + {activeConversationId ? : null}
diff --git a/application/v2_ui/src/lib/actionCredentialEvents.ts b/application/v2_ui/src/lib/actionCredentialEvents.ts index ba446538e..5eee0ff09 100644 --- a/application/v2_ui/src/lib/actionCredentialEvents.ts +++ b/application/v2_ui/src/lib/actionCredentialEvents.ts @@ -15,6 +15,10 @@ export function readCredentialRequest(value: unknown): CredentialRequest { !Array.isArray(value.requirements) || !Array.isArray(value.identities)) { throw new Error('The credential request could not be read. Reload this conversation.'); } + if (value.conversation_kind !== undefined && value.conversation_kind !== 'personal' && + value.conversation_kind !== 'collaborative') { + throw new Error('The credential request belongs to an unsupported conversation.'); + } const requirements = value.requirements.map((item) => { if (!record(item) || !slotType(item.slot) || !authType(item.auth_type) || (item.slot === 'proxy' && item.auth_type !== 'username_password')) { @@ -29,6 +33,7 @@ export function readCredentialRequest(value: unknown): CredentialRequest { id: text(value.id), etag: text(value.etag), conversation_id: text(value.conversation_id), action_name: text(value.action_name), destination_label: text(value.destination_label), reason: text(value.reason), status: text(value.status), requirements, + ...(value.conversation_kind ? { conversation_kind: value.conversation_kind } : {}), identities: value.identities.flatMap((item) => record(item) && text(item.id) ? [{ id: text(item.id), name: text(item.name), auth_type: text(item.auth_type), ...(text(item.etag) ? { etag: text(item.etag) } : {}) }] : []), diff --git a/application/v2_ui/src/lib/actionCredentialTypes.ts b/application/v2_ui/src/lib/actionCredentialTypes.ts index c878c215d..9704f108d 100644 --- a/application/v2_ui/src/lib/actionCredentialTypes.ts +++ b/application/v2_ui/src/lib/actionCredentialTypes.ts @@ -1,5 +1,7 @@ // actionCredentialTypes.ts +import type { ConversationKind } from './endpoints'; + export type CredentialSlot = 'service' | 'proxy'; export type PersonalCredentialAuth = 'username_password' | 'api_key' | 'bearer_token'; @@ -12,6 +14,7 @@ export interface CredentialRequest { id: string; etag: string; conversation_id: string; + conversation_kind?: ConversationKind; action_name: string; destination_label: string; reason: string; diff --git a/application/v2_ui/src/lib/actionCredentials.ts b/application/v2_ui/src/lib/actionCredentials.ts index e2d92b7de..cb0aa03fb 100644 --- a/application/v2_ui/src/lib/actionCredentials.ts +++ b/application/v2_ui/src/lib/actionCredentials.ts @@ -34,6 +34,7 @@ export async function fetchActionCredentialRequest( ); const current = readCredentialRequest(result.credential_request); if (current.id !== credentialRequest.id || current.conversation_id !== credentialRequest.conversation_id || + (current.conversation_kind ?? 'personal') !== (credentialRequest.conversation_kind ?? 'personal') || current.run_id !== credentialRequest.run_id || current.turn_id !== credentialRequest.turn_id) { throw new Error('The credential request no longer belongs to this work.'); } @@ -61,6 +62,8 @@ export async function saveActionCredentials( ); const updated = readCredentialRequest(result.credential_request); if (updated.id !== credentialRequest.id || updated.conversation_id !== credentialRequest.conversation_id || + (updated.conversation_kind ?? 'personal') !== (credentialRequest.conversation_kind ?? 'personal') || + updated.run_id !== credentialRequest.run_id || updated.turn_id !== credentialRequest.turn_id || updated.status !== 'ready') { throw new Error('Saving could not be confirmed. Reload this credential request before continuing.'); } diff --git a/application/v2_ui/src/stores/chatStore.ts b/application/v2_ui/src/stores/chatStore.ts index 9a2bbe5df..1254c0430 100644 --- a/application/v2_ui/src/stores/chatStore.ts +++ b/application/v2_ui/src/stores/chatStore.ts @@ -1680,7 +1680,9 @@ async function runChatStream( */ export async function continueCredentialChat(credentialRequest: CredentialRequest): Promise { const state = useChatStore.getState(); - if (state.activeConversationId !== credentialRequest.conversation_id || state.streaming) { + const kind = credentialRequest.conversation_kind ?? 'personal'; + if (state.activeConversationId !== credentialRequest.conversation_id || state.streaming || + (state.activeConversationKind && state.activeConversationKind !== kind)) { throw new Error('Open this conversation and wait for its current response before continuing.'); } useChatStore.setState({ @@ -1693,6 +1695,7 @@ export async function continueCredentialChat(credentialRequest: CredentialReques let failure: Error | null = null; await runChatStream(null, credentialRequest.conversation_id, { reloadOnDone: true, + kind, transport: async (handlers, signal) => { try { const response = await openCredentialContinuation(credentialRequest, signal); diff --git a/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md b/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md index c624d7ba5..6a5f14a7f 100644 --- a/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md +++ b/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md @@ -2,7 +2,7 @@ Fixed/Implemented in version: **0.261.333**. -Current application version: **0.261.346**. +Current application version: **0.261.348**. Related version update: `application/single_app/config.py`. @@ -92,16 +92,38 @@ endpoint. ### Dedicated credential interaction The SSE event is `action_credentials_required`, with a safe -`credential_request`. Both chat interfaces recover pending requests for the -owned conversation. +`credential_request`. Both chat interfaces recover only the invoking actor's +pending requests for a currently authorized personal, group-scoped, or shared +conversation. The credential form is distinct from ordinary elicitation. Its inputs are ephemeral and submitted only to the credential API. They do not enter generic prompt drafts, tool/model arguments, chat transcripts, exports, telemetry, -browser storage, or collaboration views. Input elements are cleared on +browser storage, or other participants' collaboration views. Input elements are cleared on submission, cancellation, unmount, and conversation/account changes. This is a data-flow boundary, not a promise that JavaScript can zero all browser memory. +### Shared conversation routing + +Fixed/Implemented in version: **0.261.348**. Credential-specific authorization +maps the visible shared conversation to its verified hidden execution source. +It does not relax the general personal-conversation authorizer. Requests and +checkpoints retain their original execution ID in the invoking user's partition; +the private browser DTO uses the visible conversation ID and +`conversation_kind: "collaborative"`. + +The server records and verifies the original shared AI question and its sender. +Live credential events stay on the requesting HTTP stream, never the shared +event registry. Reopening the shared conversation recovers only the current +actor's prompts. Empty authorized lists are successful responses, not connection +errors. This works with each actor's own authorized personal agents without +making those agents available to the other participants. + +Current participation, group status, source linkage and original question are +rechecked before saving, resuming and publishing. Shared Orchestrate continues +to use its same-ID backing and existing creator-only authorization. Credential +reads do not create a backing conversation or persist missing participant state. + ### Continuation Credential collection releases the stream rather than keeping a worker @@ -125,6 +147,14 @@ save alone is not a completed action. Existing failed requests are not migrated or replayed. See the [chat reconstruction fix](../fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md). +For shared chat, the resumed answer is screened and mirrored into the shared +thread with the original question, agent and invoking-actor attribution. Stable +answer IDs prevent duplicate messages. If publication fails after the journal +has completed, an explicit refreshed continuation can deliver the retained +answer without calling the external action again. Private request IDs, identity +choices and secrets are not published with that answer. See the +[shared-chat fix](../fixes/SHARED_CHAT_ACTION_CREDENTIALS_FIX.md). + Yamcs is read-only. Future write-capable adapters must handle operation journaling and uncertain effects before acquiring equivalent continuation support. @@ -133,7 +163,7 @@ support. | Endpoint | Purpose | | --- | --- | -| `GET /api/action-credentials/pending?conversation_id=...` | Recover safe outstanding requests for an owned conversation. | +| `GET /api/action-credentials/pending?conversation_id=...` | Recover the current actor's safe outstanding requests for an authorized conversation, including a visible shared ID. | | `GET /api/action-credentials/` | Read an authorized request's safe current state. | | `POST /api/action-credentials//submit` | Select compatible personal identities or save new credentials for the requested slots. | | `POST /api/action-credentials//continue` | Resume the server-owned blocked work, using only the expected request revision. | @@ -199,6 +229,14 @@ continuation. `ui_tests/test_v2_action_credentials_backend.py` additionally drives the bundled React card through real Flask routes and checks that the provider result becomes the same conversation's assistant response. +Shared routing and privacy are covered by +`functional_tests/test_action_credentials_shared_conversations.py` and the +shared scenarios in `test_action_credentials_chat_reconstruction.py`: private +per-actor lists, read-only recovery, membership/origin revocation, both live +event formats, one external invocation, and idempotent shared answer delivery. +The V2 browser suites cover shared routing, empty states, error-specific copy, +single-flight retry, stale-response isolation, and real shared Flask history. + Local validation for **0.261.333** passed 44 dedicated backend tests and 36 credential browser cases, plus the React type check and production build. That initial broader Cosmos contract run was non-green: five existing cold-import diff --git a/docs/explanation/fixes/SHARED_CHAT_ACTION_CREDENTIALS_FIX.md b/docs/explanation/fixes/SHARED_CHAT_ACTION_CREDENTIALS_FIX.md new file mode 100644 index 000000000..d6fb1a597 --- /dev/null +++ b/docs/explanation/fixes/SHARED_CHAT_ACTION_CREDENTIALS_FIX.md @@ -0,0 +1,82 @@ +# Shared chat action credentials fix + +Fixed/Implemented in version: **0.261.348**. + +Related version update: `application/single_app/config.py`. + +## Issue and root cause + +A working shared conversation displayed an action-credential load error even +when no action was waiting for a sign-in. Its pending-request lookup treated +the visible shared ID as a personal conversation ID. Shared messages loaded +successfully from collaboration storage while that lookup returned 404. +Reloading repeated the same incorrect lookup. + +Action credentials are private service/proxy authentication for an action, +currently Yamcs. They are not SimpleChat login or access to another participant's +personal agent. The check runs when a conversation opens; it must succeed with +an empty list when the user has no unfinished action. + +## Changes + +`functions_action_credential_conversations.py` resolves personal and verified +shared execution contexts using injected storage and existing authorization +policies. It checks current participation, source ownership/linkage, and the +original AI question's sender. The general personal-conversation authorizer +continues to reject shared Orchestrate backings. + +The credential runtime and API keep requests/checkpoints in the invoking +actor's partition under the execution ID. Only safe browser DTOs use the +visible shared ID. Pending reads are read-only, including legacy accepted +group participants whose user-state record has not been materialized. +Unavailable storage remains an explicit error, not a successful empty list. + +The shared stream translates both credential SSE formats privately for its +requesting actor. It never broadcasts credential forms, identity choices or +private request IDs. Chat continuation reconstructs the actor's own agent, +reauthorizes retained sources and tool execution, and screens the resulting +answer before mirroring it into the original shared thread. + +Stable answer IDs and the completed execution journal allow an explicit +delivery retry after publication failure without repeating completed tool +operations or posting another question. Uncertain execution failures do not +acquire this delivery-only retry. Existing ETags, CSRF, expiry, action +fingerprints, journal guards, and creator-only shared Orchestrate remain. + +V2 credential cards wait for the conversation kind to resolve, clear on +conversation/account changes, and continue through the correct history family. +Load failures distinguish session/access problems from transient request-check +errors. Reload is single-flight and visibly busy. + +## Behavior and limitations + +Before the fix, a valid shared ID could produce a persistent, misleading +credential banner. After the fix, no unfinished work means no banner; an actual +blocked action shows only its invoking user's sign-in card. Continuing returns +the normal answer to everyone in the shared thread, not the credentials. + +Each participant may use their own authorized personal agents. Sharing a +conversation does not share personal agents or identities. Shared Orchestrate +is still creator-only. Requests expire after 24 hours, and old shared requests +without a verified original question are not guessed or replayed. + +## Validation + +`functional_tests/test_action_credentials_shared_conversations.py` covers +private per-actor lists, guessed-ID denial, revoked access, forged/deleted +origins, expiration, storage errors and private live-stream projection. + +`functional_tests/test_action_credentials_chat_reconstruction.py` exercises +real loaders, identity storage, journals and routes for personal/group actions +in shared personal/group chats, including noncreator personal agents, +read-only legacy membership and delivery retry without a second provider call. + +`ui_tests/test_v2_action_credentials.py` covers empty/error states, single-flight +retry, stale responses, secret clearing and personal/shared routing. +`ui_tests/test_v2_action_credentials_backend.py` drives the real React card +through Flask and a freshly reconstructed agent into shared history. Classic +cards and existing credential/orchestration regressions retain their coverage. + +All external service/model/Cosmos transport in these integration tests is +offline. They do not submit production credentials, invoke real external +actions, or deploy the change. diff --git a/docs/guides/personal-action-credentials.md b/docs/guides/personal-action-credentials.md index 5fc389906..c1236775a 100644 --- a/docs/guides/personal-action-credentials.md +++ b/docs/guides/personal-action-credentials.md @@ -4,7 +4,7 @@ title: "Use your own credentials with a shared action" description: "Provide a private personal identity when a shared Yamcs action needs your sign-in." section: "Guides" audience: user -version: "0.261.345" +version: "0.261.348" --- ## What this does @@ -20,6 +20,8 @@ Fixed/Implemented in version: **0.261.333**, recorded in Continuation failure feedback and status recovery were fixed in **0.261.343**. Fresh agent reconstruction for new same-conversation requests was fixed in **0.261.345**. +Private prompts and continuation in shared conversations were fixed in +**0.261.348**. ## When a credential card appears @@ -28,6 +30,10 @@ authorized action directly. A custom agent is not required. If the action requires your personal identity and none is bound to it, chat pauses that work and shows a **Personal action credentials** card. +This is the action's connection to its configured service, not your SimpleChat +login. Checking for unfinished requests does not mean that an action needs +credentials; a conversation with no unfinished requests shows no card. + The card names the action and destination. A proxy sign-in is not necessarily your Yamcs sign-in: a deployment can have proxy authentication while Yamcs itself has none. Follow the labels rather than supplying the same credential @@ -56,6 +62,22 @@ Saving credentials does not grant access to an action or approve an unrelated write operation. The action's governance and current group membership still apply. +## Using an agent in a shared conversation + +You can invoke your own personal agent in a shared personal or group conversation +when that agent is available to you. Another participant can use their own +personal agent, but sharing the conversation does not let either of you select +the other's personal agents or identities. + +An action sign-in card belongs only to the person who invoked the action. Other +participants cannot see its identity choices, enter credentials on your behalf, +or continue your paused work. After you continue, the normal assistant answer +is posted to the shared thread as a reply to your original question. Consider +what your agent may return before asking it to share information with the group. + +Shared **Orchestrate** remains available only to the person who started the +conversation, while they are still a participant. + ## Returning later or cancelling Saved personal identities are reused on subsequent authorized invocations. @@ -87,6 +109,15 @@ the server may already have saved them even if the response was lost. If refresh fails too, reload the conversation. Neither refresh nor reload automatically replays the action or sends the original question again. +If a completed shared answer could not be delivered, refreshing its saved +request can make **Continue** available to retry delivery. This uses the retained +answer, not a second call to the external action. + +If the unfinished-request check fails, **Reload requests** checks again and +shows **Checking requests...** while it runs. A session-expired notice requires +signing in again; an access notice requires reopening the conversation or +contacting its owner. Neither notice means you should supply a service password. + ## When credentials stop working A confirmed authentication rejection can ask for a replacement identity. @@ -121,6 +152,11 @@ connectors retain their existing authentication behavior. Application managed identity and SimpleChat's session cookie are not personal credentials to paste into a card. Scheduled/background prompting is outside this release. +Requests expire after 24 hours. Removed conversation/group access, a deleted +original shared question, or a changed agent/action can prevent continuation. +Older shared requests without a verified original question are not automatically +reconstructed or replayed. + ## Related - [Yamcs action]({{ '/reference/actions/yamcs/' | relative_url }}) diff --git a/docs/reference/chat-controls.md b/docs/reference/chat-controls.md index 23055e4f1..dc20411fe 100644 --- a/docs/reference/chat-controls.md +++ b/docs/reference/chat-controls.md @@ -62,6 +62,13 @@ credentials; it is not an ordinary chat message or a generic follow-up question. | Cancel blocked work | Cancels the outstanding credential request rather than continuing with missing credentials. | Pending or ready credential request | | Reload requests | Reads current request state after a save/continuation conflict or transport error. Unsaved secret inputs are not recovered. | Credential request load/recovery error | +Shared-conversation routing was fixed in **0.261.348**. These cards are private +to the invoking participant even when the eventual answer is shared. Each +participant can use their own authorized personal agents, not another person's +agents or identities. **Reload requests** becomes **Checking requests...** +and is disabled during its check. An empty list shows no credential notice; +session/access errors are distinguished from a failed request-state check. + Credential values go directly to the credential API, never the composer, model, or shared transcript. See [Use your own credentials with a shared action]({{ '/guides/personal-action-credentials/' | relative_url }}). diff --git a/functional_tests/cosmos_response_boundaries.json b/functional_tests/cosmos_response_boundaries.json index f08d70c7e..2c600bd69 100644 --- a/functional_tests/cosmos_response_boundaries.json +++ b/functional_tests/cosmos_response_boundaries.json @@ -591,8 +591,6 @@ "application/single_app/functions_mcp_server_tools.py::_load_legacy_messages": "0113be12ba7f24aade864c0929ea46be0727d4039d1bb68b0f0d59423ba2feee", "application/single_app/functions_mcp_server_tools.py::_load_personal_prompt_page": "05845d307473e30980aafde342c9bedd4fd4b10b7427e34ef49e023bf90ee1a8", "application/single_app/functions_mcp_tool_pinning.py::persist_mcp_tool_drift": "d4973e7c62d1e6987b4070fb3c404cc7b11413bbe7dbd20954dea0f028eb4fe4", - "application/single_app/functions_orchestration_collaboration.py::_refresh_mirrored_answer": "7f0eaa8812e33322262da8a5c208324b9f9a781b475f358f61061c947bacd996", - "application/single_app/functions_orchestration_collaboration.py::delete_orchestration_backing.read_backing": "0dbd48b611e5c0d277f645ddea3bf692284e6cfc163b1bfe1b1570e9fd91c593", "application/single_app/functions_orchestration_runs.py::get_pending_turn_context": "ea9e8ea9d5c26b68abeaffd369cdeaad0df0a5bc5748d1d4df014de740479b4b", "application/single_app/functions_orchestration_runs.py::_replanned_outcome": "7c8dfe316d74c544b48b4ca595151386be1a3331c36be18c06a62158e39bb5b7", "application/single_app/functions_orchestration_runs.py::_publish_replanned_run": "c79252d36d79df63ffdc38628206de5496eb0cac7c4f0b0f7296281d699ff4c1", @@ -732,12 +730,6 @@ "application/single_app/functions_workspace_authoring.py::save_global_editor_record": "96d36d2900a3da68dc8ec1d41557949f82b9f117f6f9cde9223b5cb6c7efe9a4", "application/single_app/functions_workspace_authoring.py::_assert_available_name": "9e729b2e15fe012c62dccb152a8c73449a3ea7dfae2c841abbad48d1ed9c9120", "application/single_app/route_backend_agents.py::_load_all_agent_records_for_default_migration": "23a840625a3965fe6f6fcd03519321ca9af7ad1e4b81b587e208da787afce90b", - "application/single_app/route_backend_collaboration.py::_read_source_message_doc": "5d0160630390a148ed5eda28cb03eeb0089a6231f2eb274561d89d35429fde33", - "application/single_app/route_backend_collaboration.py::_sync_collaboration_mask_metadata_to_source": "690239415e92bce6acc5ac0d8bfd1d401c539bdf6d78b8639b35958e991b2c54", - "application/single_app/route_backend_collaboration.py::_sync_collaboration_block_revisions_to_source": "11bfc552a7ac4d90e4128bcfc46c20f4c1969f46f2f46a56d1e130b4fb01f096", - "application/single_app/route_backend_collaboration.py::_find_collaboration_originating_request": "a696f050ee172546aed5a25e31ab19ec50d2574191fb7c33a30f38d91760b28b", - "application/single_app/route_backend_collaboration.py::register_route_backend_collaboration.add_collaboration_image_revision_api": "d5fc873f0aacd162c67ecc313e2e0896bd807f69049523285191d978584a2b15", - "application/single_app/route_backend_collaboration.py::register_route_backend_collaboration.stream_collaboration_message_api.generate_stream.transform_event_block": "69bdabb8c98414cfa5149c952da450c76c6833e77be4d0e1e4a68ae2a93a9f93", "application/single_app/route_backend_content_screening.py::register_route_backend_content_screening.content_screening_update_configuration": "5e28a46d1592f13669c6eee30e048d1892f8677980b123d419b42414d4243a05", "application/single_app/route_backend_control_center.py::get_group_token_totals": "423516e4c1b2d4e000d2adf4e4ec82b9fefd7794667f80dbdea1889244c5c431", "application/single_app/route_backend_control_center.py::get_group_name_map": "b739fa7523308c1ddfb2586a9d1b1930da3b897d27c5d4e5403567434a7e237d", @@ -3167,7 +3159,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "get_collaboration_conversation: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", @@ -3188,7 +3180,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "get_collaboration_user_state: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", @@ -3209,7 +3201,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "get_collaboration_message: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3230,7 +3222,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "get_collaboration_message_by_source_message: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3251,7 +3243,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "ensure_collaboration_user_state_for_participant: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3262,7 +3254,7 @@ }, "application/single_app/functions_collaboration.py::_bootstrap_collaboration_user_state_from_participant": { "boundary": { - "sha256": "577ce40f786242a05f92e41ec0b02fa83cff2d95ac04f4f8dba869818c89f67b", + "sha256": "243e59da83841fc656ce010fcbaf0e101fe58f0fa50152395cc0d502ab55772b", "points": [ { "method": "upsert_item", @@ -3272,12 +3264,13 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", - "contract": "_bootstrap_collaboration_user_state_from_participant: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", + "contract": "Existing participant materialization discards write responses. The credential read-only path instead builds a transient state without either upsert; legacy accepted-group recovery checks the real state-store revision remains unchanged.", "evidence_scope": "owner-runtime", "tests": [ + "functional_tests/test_action_credentials_chat_reconstruction.py::test_shared_chat_private_prompt_and_exact_continuation", "functional_tests/test_workflow_results_delivery.py" ] }, @@ -3293,7 +3286,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "_get_collaboration_source_message: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3314,7 +3307,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "get_personal_collaboration_conversation_by_source_conversation: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3341,7 +3334,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "_copy_legacy_personal_messages_to_collaboration: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3372,7 +3365,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "ensure_personal_collaboration_for_legacy_conversation: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", @@ -3399,7 +3392,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "_copy_legacy_group_messages_to_collaboration: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3435,7 +3428,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "ensure_group_collaboration_for_legacy_conversation: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", @@ -3456,7 +3449,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "Conversation and optional agent-assignment writes ignore SDK response values; deterministic conflict reads are owned by the reviewed persistence helper.", @@ -3477,7 +3470,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "list_personal_collaboration_conversations_for_user: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3503,7 +3496,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "list_group_collaboration_conversations_for_user: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3529,7 +3522,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "record_personal_invite_response: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3555,7 +3548,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "invite_personal_collaboration_participants: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3581,7 +3574,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "remove_personal_collaboration_member: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3602,7 +3595,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "list_collaboration_messages: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3628,7 +3621,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "_save_collaboration_message_doc: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3649,7 +3642,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "sync_collaboration_conversation_metadata_from_source: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3690,7 +3683,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "SDK point responses are inspected as mappings while deterministic safe backing creation preserves scope and ownership; publication normalizes returned records.", @@ -3711,7 +3704,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "_refresh_collaboration_conversation_message_summary: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3732,7 +3725,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "delete_collaboration_message: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3753,7 +3746,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "update_personal_collaboration_title: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3774,7 +3767,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "toggle_personal_collaboration_pin: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3795,7 +3788,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "toggle_personal_collaboration_hide: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3821,7 +3814,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "update_personal_collaboration_member_role: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3852,7 +3845,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "leave_personal_collaboration_conversation: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3873,7 +3866,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "_archive_collaboration_item: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3894,7 +3887,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "response-unused", "contract": "_delete_item_if_present: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -3915,7 +3908,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "_read_linked_collaboration_source: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", @@ -3936,7 +3929,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "_cleanup_linked_collaboration_source: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -3962,7 +3955,7 @@ } ], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "_delete_collaboration_conversation_records: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", @@ -12156,13 +12149,13 @@ ], "queries": [], "batches": [], - "module_sha256": "e15f66045985f75aedce3af54f4f45963c6f82e147622e99143e1efe9bb22c69" + "module_sha256": "d91095101df3dc9c602599fcb80b8e8a19f814c7226cde580e6f3333dfd88ec5" }, "classification": "sdk-compatible", - "contract": "_refresh_mirrored_answer: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "_refresh_mirrored_answer: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_orchestration_shared_conversations.py" ] }, "application/single_app/functions_orchestration_collaboration.py::delete_orchestration_backing": { @@ -12198,7 +12191,7 @@ } ], "batches": [], - "module_sha256": "e15f66045985f75aedce3af54f4f45963c6f82e147622e99143e1efe9bb22c69" + "module_sha256": "d91095101df3dc9c602599fcb80b8e8a19f814c7226cde580e6f3333dfd88ec5" }, "classification": "response-unused", "contract": "delete_orchestration_backing: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -12217,13 +12210,13 @@ ], "queries": [], "batches": [], - "module_sha256": "e15f66045985f75aedce3af54f4f45963c6f82e147622e99143e1efe9bb22c69" + "module_sha256": "d91095101df3dc9c602599fcb80b8e8a19f814c7226cde580e6f3333dfd88ec5" }, "classification": "sdk-compatible", - "contract": "delete_orchestration_backing.read_backing: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "delete_orchestration_backing.read_backing: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_orchestration_shared_conversations.py" ] }, "application/single_app/functions_orchestration_context.py::_image_reference_message_candidates": { @@ -20121,13 +20114,13 @@ } ], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "sdk-compatible", - "contract": "_read_source_message_doc: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "_read_source_message_doc: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_action_credentials_shared_conversations.py::test_live_shared_stream_keeps_both_credential_event_formats_private" ] }, "application/single_app/route_backend_collaboration.py::_sync_collaboration_mask_metadata_to_source": { @@ -20147,13 +20140,13 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "sdk-compatible", - "contract": "_sync_collaboration_mask_metadata_to_source: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "_sync_collaboration_mask_metadata_to_source: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_action_credentials_shared_conversations.py::test_live_shared_stream_keeps_both_credential_event_formats_private" ] }, "application/single_app/route_backend_collaboration.py::_sync_collaboration_block_revisions_to_source": { @@ -20173,13 +20166,13 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "sdk-compatible", - "contract": "_sync_collaboration_block_revisions_to_source: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "_sync_collaboration_block_revisions_to_source: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_action_credentials_shared_conversations.py::test_live_shared_stream_keeps_both_credential_event_formats_private" ] }, "application/single_app/route_backend_collaboration.py::_save_collaboration_image_revisions": { @@ -20199,7 +20192,7 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "response-unused", "contract": "_save_collaboration_image_revisions: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -20218,7 +20211,7 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "response-unused", "contract": "_save_collaboration_block_revisions: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -20237,13 +20230,13 @@ } ], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "sdk-compatible", - "contract": "_find_collaboration_originating_request: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "_find_collaboration_originating_request: query rows/pages are plain JSON, not Cosmos point wrappers. Nested domain shape checks therefore need no point-response repair. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_action_credentials_shared_conversations.py::test_live_shared_stream_keeps_both_credential_event_formats_private" ] }, "application/single_app/route_backend_collaboration.py::register_route_backend_collaboration.mask_collaboration_message_api": { @@ -20258,7 +20251,7 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "response-unused", "contract": "register_route_backend_collaboration.mask_collaboration_message_api: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -20277,13 +20270,13 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "sdk-compatible", - "contract": "register_route_backend_collaboration.add_collaboration_image_revision_api: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "register_route_backend_collaboration.add_collaboration_image_revision_api: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. Representative SDK-default runtime coverage now executes this shared-storage module; this does not claim exhaustive branch coverage for this unchanged owner.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_action_credentials_shared_conversations.py::test_live_shared_stream_keeps_both_credential_event_formats_private" ] }, "application/single_app/route_backend_collaboration.py::register_route_backend_collaboration.set_collaboration_message_visual_style_api": { @@ -20298,7 +20291,7 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "response-unused", "contract": "register_route_backend_collaboration.set_collaboration_message_visual_style_api: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", @@ -20307,7 +20300,7 @@ }, "application/single_app/route_backend_collaboration.py::register_route_backend_collaboration.stream_collaboration_message_api": { "boundary": { - "sha256": "7c440d8651f4cbfdb65c259ebe698fe7f9de2c643186e64e1c7ef0e24de48f48", + "sha256": "9d221091867db40566421803a650bcae021f9c7a2fa1b147dba0b263566b335d", "points": [ { "method": "upsert_item", @@ -20317,16 +20310,18 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "response-unused", - "contract": "register_route_backend_collaboration.stream_collaboration_message_api: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible.", - "evidence_scope": "source-review", - "tests": [] + "contract": "register_route_backend_collaboration.stream_collaboration_message_api: point response values are discarded; query rows, if present, are separate plain SDK JSON. Existing write errors remain visible. The new credential branch authorizes the private request against the actor and verified original shared question, returns a safe visible-ID DTO only on its HTTP stream, and never publishes it. Both event formats execute under the real shared route.", + "evidence_scope": "owner-runtime", + "tests": [ + "functional_tests/test_action_credentials_shared_conversations.py::test_live_shared_stream_keeps_both_credential_event_formats_private" + ] }, "application/single_app/route_backend_collaboration.py::register_route_backend_collaboration.stream_collaboration_message_api.generate_stream.transform_event_block": { "boundary": { - "sha256": "365afb6eef02a0d5fdbb11f91a9e1d10d24eef0e4196ada527860748edba55d5", + "sha256": "e3f90fdcd656acdc6e0c4719a4c6c31ff9fbcd70ad63d9a71e21e489794e21e6", "points": [ { "method": "read_item", @@ -20346,13 +20341,13 @@ ], "queries": [], "batches": [], - "module_sha256": "019de1e7a6fd41fd216eee70d0fa248807bf6731a2faa90f62b10e26cd3acda9" + "module_sha256": "9fda624ed7379d68dfd7ce971506d1e058f0cb973baddd71f95ded2bc942d15b" }, "classification": "sdk-compatible", - "contract": "register_route_backend_collaboration.stream_collaboration_message_api.generate_stream.transform_event_block: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners.", - "evidence_scope": "sdk-api-and-source-review", + "contract": "register_route_backend_collaboration.stream_collaboration_message_api.generate_stream.transform_event_block: point documents use Mapping field reads/JSON encoding/metadata projections; batch status/body lists are separate, never top-level-normalized as documents. Strict domain sinks remain with their storage owners. The new credential branch authorizes the private request against the actor and verified original shared question, returns a safe visible-ID DTO only on its HTTP stream, and never publishes it. Both event formats execute under the real shared route.", + "evidence_scope": "owner-runtime", "tests": [ - "functional_tests/test_m365_cosmos_sdk_contract.py::test_real_sdk_point_documents_are_wrapped_but_nested_json_and_query_rows_are_plain" + "functional_tests/test_action_credentials_shared_conversations.py::test_live_shared_stream_keeps_both_credential_event_formats_private" ] }, "application/single_app/route_backend_content_screening.py::register_route_backend_content_screening.content_screening_update_configuration": { @@ -25757,7 +25752,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "Deterministic conversation conflict reads retain SDK mappings for creator, scope and provenance checks; callers normalize at presentation boundaries.", @@ -25778,7 +25773,7 @@ ], "queries": [], "batches": [], - "module_sha256": "c884d26e86a53d4bacc87e41aad145927c66b5a7f4bf3acf5a471defcdc83292" + "module_sha256": "f5a3a8781c98ccb3124f28ebc916b0e884ee5067b445c029f771e35cf1c31a64" }, "classification": "sdk-compatible", "contract": "Deterministic result-message point reads validate source identity and repair summary after partial writes without duplicating messages or resetting user state.", @@ -25942,6 +25937,28 @@ "functional_tests/test_workflow_results_delivery.py" ], "evidence_scope": "owner-runtime" + }, + "application/single_app/functions_action_credential_conversations.py::CredentialConversations._read": { + "boundary": { + "sha256": "3e86d17a7e5aaabed5761966c9c83b2ad42c031fa45121eb750d645d8624b441", + "points": [ + { + "method": "read_item", + "receiver": "container", + "response": "consumed" + } + ], + "queries": [], + "batches": [], + "module_sha256": "95d6755194e6e9bcb1f2cf5090788555147b0734479993867536d6c880a24901" + }, + "classification": "normalized-strict", + "contract": "SDK CosmosDict point responses normalize to a plain dictionary with ETag before credential context/origin validation. Not-found remains distinct from invalid response/storage failure; no writes. Real Flask shared recovery and continuation use SDK-default points.", + "tests": [ + "functional_tests/test_action_credentials_shared_conversations.py", + "functional_tests/test_action_credentials_chat_reconstruction.py::test_shared_chat_private_prompt_and_exact_continuation" + ], + "evidence_scope": "owner-runtime" } }, "doubles": { @@ -32707,6 +32724,21 @@ "functional_tests/test_workspace_metadata_outcomes.py::test_metadata_outcome_round_trips_through_sdk_access_projection", "functional_tests/test_workspace_metadata_outcomes.py::test_public_picker_route_uses_sdk_projection_and_filtered_paging" ] + }, + "functional_tests/test_action_credentials_shared_conversations.py::test_storage_failure_remains_explicit_and_safe::point-seam-1": { + "boundary": { + "sha256": "c670cfa0fba919627d4f8597369356ad8bbb01acbcb2eec7210c7e57cc28c3a9", + "methods": [ + "read_item" + ], + "kind": "seam", + "imports_sha256": "4876bad40185b8f5f20a21552285691746c2c09c0c3b013295da317e139785e8" + }, + "classification": "fault-injection", + "contract": "Scoped read_item failure injection raises an SDK 503 containing a private sentinel. The real credential route must return safe 503 JSON with no-store, not an empty success or private details; all successful points retain the installed SDK default.", + "tests": [ + "functional_tests/test_action_credentials_shared_conversations.py::test_storage_failure_remains_explicit_and_safe" + ] } } } diff --git a/functional_tests/test_action_credentials_chat_reconstruction.py b/functional_tests/test_action_credentials_chat_reconstruction.py index 0872e1045..f6f220fb6 100644 --- a/functional_tests/test_action_credentials_chat_reconstruction.py +++ b/functional_tests/test_action_credentials_chat_reconstruction.py @@ -1,7 +1,7 @@ # test_action_credentials_chat_reconstruction.py """Fresh-agent save/continue integration through real application owners. -Version: 0.261.345 +Version: 0.261.348 Implemented in: 0.261.345 Cold subprocesses keep bootstrap, loaders, authorization, identities, journals, @@ -18,6 +18,7 @@ PROBE = r''' import asyncio import base64 +import hashlib from contextlib import ExitStack, nullcontext from copy import deepcopy from functools import wraps @@ -54,6 +55,11 @@ def check(value, message): import functions_authentication as authentication import route_backend_action_credentials as credential_routes import functions_appinsights as telemetry + import functions_collaboration as collaboration + from collaboration_models import ( + build_personal_collaboration_conversation, build_group_collaboration_conversation, + build_collaboration_user_state, + ) from action_credential_contracts import ActionCredentialsRequired from flask import g, session from semantic_kernel import Kernel @@ -67,6 +73,7 @@ def check(value, message): "enable_user_workspace": True, "per_user_semantic_kernel": True, "merge_global_semantic_kernel_with_workspace": True, "enable_key_vault_secret_storage": False, "enable_content_safety": False, + "enable_collaborative_conversations": True, "enable_default_embedding_model_plugin": False, "enable_tabular_processing": False, "azure_openai_gpt_endpoint": "https://model.example.invalid", "azure_openai_gpt_key": "local-model-key", "azure_openai_gpt_deployment": "test-model", @@ -78,6 +85,8 @@ def check(value, message): "instructions": "List the available telemetry instances.", "actions_to_load": ["mission"], } scope = scenario.get("scope", "global") + shared_kind = scenario.get("shared") + creator = "creator" if scenario.get("participant") else "owner" if scope != "global": target.update(is_global=False, is_group=scope == "group") target["group_id" if scope == "group" else "user_id"] = "team" if scope == "group" else "owner" @@ -103,12 +112,14 @@ def check(value, message): agent_container.upsert_item(target) action_container.upsert_item(action) config.cosmos_groups_container.upsert_item({ - "id": "team", "name": "Telemetry Team", "owner": {"id": "owner"}, "users": [{"userId": "owner"}], + "id": "team", "name": "Telemetry Team", "owner": {"id": "owner"}, + "users": [{"userId": "owner"}, {"userId": "creator"}], }) user_settings = {"settings": {"enable_agents": True, "selected_agent": target}} if scope == "group": user_settings["settings"]["activeGroupOid"] = "team" state.configure_action_credential_state(CredentialContainer("user_id")) + shared_events = [] models, provider_calls, completed_calls, histories, diagnostics, responses = [], [], [], [], [], [ ChatMessageContent(role="assistant", items=[ FunctionCallContent(id="completed-sum", name="math-Add", arguments='{"input":2,"amount":3}'), @@ -191,6 +202,12 @@ def capture_diagnostic(message, *args, **kwargs): deepcopy(row) for row in config.cosmos_messages_container.items.values() if row.get("conversation_id") == "credential-chat" ]), + patch.object(config.cosmos_collaboration_messages_container, "query_items", side_effect=lambda **kwargs: [ + deepcopy(row) for row in config.cosmos_collaboration_messages_container.items.values() + if row.get("conversation_id") == "visible-chat" + ]), + patch.object(collaboration, "publish_collaboration_event", + side_effect=lambda conversation_id, event: shared_events.append(deepcopy(event))), patch.object(loader, "create_model_endpoint_chat_completion_service", side_effect=model_service), patch.object(AzureChatCompletion, "_inner_get_chat_message_contents", model_reply), patch.object(AzureChatCompletion, "_inner_get_streaming_chat_message_contents", stream_reply), @@ -209,11 +226,45 @@ def capture_diagnostic(message, *args, **kwargs): "password": "old-password" if scenario["identity"] == "rotate" else "private-password-sentinel"}, }, "owner", identity_id="existing-proxy") selected_identity = identities.get_workspace_identity("personal", "owner", "existing-proxy") - with web.app.test_request_context("/"): + origin_payload = {"m365_collaboration_message_id": "shared-question"} if shared_kind else {} + with web.app.test_request_context("/", json=origin_payload): session["user"] = {"oid": "owner", "roles": ["User"]} g.conversation_id = "credential-chat" g.request_agent_info = deepcopy(target) chats._create_personal_conversation("owner", "credential-chat") + if shared_kind: + builder = ( + build_group_collaboration_conversation + if shared_kind == "group" else build_personal_collaboration_conversation + ) + shared = builder( + title="Shared telemetry", creator_user={"user_id": creator, "display_name": "Creator"}, + invited_participants=[{"user_id": "owner", "display_name": "Actor"}] if creator != "owner" else [], + conversation_id="visible-chat", + **({"group_id": "team", "group_name": "Telemetry Team"} if shared_kind == "group" else {}), + ) + shared["source_conversation_id"] = "credential-chat" + for participant in shared.get("participants", []): + participant["status"] = "accepted" + config.cosmos_collaboration_user_state_container.upsert_item(build_collaboration_user_state( + conversation_doc=shared, user_summary=participant, + role=participant["role"], membership_status="accepted", + )) + shared["accepted_participant_ids"] = list({creator, "owner"}) + config.cosmos_collaboration_conversations_container.upsert_item(shared) + source = dict(config.cosmos_conversations_container.read_item("credential-chat", "credential-chat")) + source.update( + conversation_kind="collaboration_source", collaboration_conversation_id="visible-chat", + user_id=creator, is_hidden=True, + ) + config.cosmos_conversations_container.upsert_item(source) + config.cosmos_collaboration_messages_container.upsert_item({ + "id": "shared-question", "conversation_id": "visible-chat", "role": "user", + "content": "List the actual telemetry instances.", "message_kind": "ai_request", + "timestamp": "2026-10-17T12:00:00Z", + "metadata": {"sender": {"user_id": "owner", "display_name": "Actor"}, + "source_conversation_id": "credential-chat"}, + }) user_message = { "id": "original-question", "conversation_id": "credential-chat", "user_id": "owner", "role": "user", "content": "List the actual telemetry instances.", @@ -229,6 +280,16 @@ def capture_diagnostic(message, *args, **kwargs): }) config.cosmos_messages_container.upsert_item(user_message) canonical = runtime.resolve_delegation_agent(target, user_id="owner", settings=settings) + if shared_kind and scope == "personal": + try: + runtime.resolve_delegation_agent( + {**canonical, "scope_id": "another-participant", "user_id": "another-participant"}, + user_id="owner", settings=settings, + ) + except PermissionError: + pass + else: + raise AssertionError("Sharing a conversation granted another participant's personal agent.") if profile == "global": original_kernel, agents = loader.load_semantic_kernel(Kernel(), settings) elif profile == "agent-core": @@ -259,6 +320,15 @@ def capture_diagnostic(message, *args, **kwargs): pending = error.credential_request else: raise AssertionError("Tool did not pause for personal credentials.") + if shared_kind: + check(pending["conversation_id"] == "visible-chat", "Prompt used the hidden execution ID.") + check(pending["conversation_kind"] == "collaborative", "Prompt lost shared routing.") + retained = state.read("owner", pending["id"]) + check(retained["conversation_id"] == "credential-chat", "Private execution ID was rewritten.") + check(retained["continuation"]["collaboration_message_id"] == "shared-question", + "The trusted original question was not retained.") + if scenario.get("legacy_participant_state"): + config.cosmos_collaboration_user_state_container.items.pop("owner:visible-chat") check(not provider_calls and len(completed_calls) == 1, "Initial tool boundaries were not retained.") checkpoint = state.read("owner", execution.credential_checkpoint_id) check("root" in checkpoint["value"], "No retained root tool history.") @@ -266,6 +336,20 @@ def capture_diagnostic(message, *args, **kwargs): with client.session_transaction() as browser: browser["user"] = {"oid": "owner", "roles": ["User"]} path = "/api/action-credentials/" + pending["id"] + visible_id = "visible-chat" if shared_kind else "credential-chat" + state_revision = config.cosmos_collaboration_user_state_container.revision + refreshed = client.get("/api/action-credentials/pending", query_string={"conversation_id": visible_id}) + check(refreshed.status_code == 200 and refreshed.json["requests"][0]["id"] == pending["id"], + "Reopening the conversation did not recover the private prompt.") + check(config.cosmos_collaboration_user_state_container.revision == state_revision, + "Reading pending requests changed participant state.") + if shared_kind: + with client.session_transaction() as browser: + browser["user"] = {"oid": creator if creator != "owner" else "outsider", "roles": ["User"]} + other_request = client.get(path) + check(other_request.status_code == 404, "Another participant accessed the actor's private request.") + with client.session_transaction() as browser: + browser["user"] = {"oid": "owner", "roles": ["User"]} info = client.get(path) check(info.status_code == 200, "Credential request was not authorized.") slot = { @@ -304,14 +388,36 @@ def capture_diagnostic(message, *args, **kwargs): elif mutation == "group": config.cosmos_groups_container.items["team"]["owner"] = {"id": "other-user"} config.cosmos_groups_container.items["team"]["users"] = [] + elif mutation == "shared_membership": + for row in config.cosmos_collaboration_user_state_container.items.values(): + if row.get("user_id") == "owner": + row["membership_status"] = "removed" + elif mutation == "shared_question": + config.cosmos_collaboration_messages_container.items["shared-question"]["metadata"]["is_deleted"] = True + elif mutation == "shared_link": + config.cosmos_collaboration_conversations_container.items["visible-chat"]["source_conversation_id"] = "forged" persistence = ( patch.object(chats, "_persist_screened_assistant", side_effect=OSError("private-password-sentinel")) if scenario.get("persistence_failure") else nullcontext() ) - with persistence: + publication = ( + patch.object(collaboration, "publish_collaboration_event", side_effect=OSError("private-password-sentinel")) + if scenario.get("publication_failure") else nullcontext() + ) + with persistence, publication: continued = client.post(path + "/continue", json={"expected_etag": ready["etag"]}, headers={"X-CSRFToken": info.json["csrf_token"]}, buffered=True) frames = continued.get_data(as_text=True) + if scenario.get("publication_failure"): + check('"done": true' not in frames and len(provider_calls) == 1, "Failed publication reported success.") + check(state.read("owner", pending["id"])["status"] == "ready", "Saved answer cannot be explicitly retried.") + refreshed = client.get(path) + check(refreshed.status_code == 200, "Saved delivery request could not be refreshed.") + ready = refreshed.json["credential_request"] + continued = client.post(path + "/continue", json={"expected_etag": ready["etag"]}, + headers={"X-CSRFToken": info.json["csrf_token"]}, buffered=True) + frames = continued.get_data(as_text=True) + check(len(provider_calls) == 1 and len(completed_calls) == 1, "Delivery retry replayed external work.") if scenario.get("persistence_failure") or scenario.get("provider_status") == 401: expected_status = "failed" if scenario.get("persistence_failure") else "superseded" check('"done": true' not in frames and len(provider_calls) == 1, "Failure became success or replayed work.") @@ -349,14 +455,34 @@ def capture_diagnostic(message, *args, **kwargs): "Same-conversation continuation failed: " + frames) check(len(provider_calls) == 1, "Blocked provider call did not execute exactly once.") check(len(completed_calls) == 1, "An earlier completed tool was replayed.") - check(len(models) == 2 and models[0] is not models[1], "Resume did not create a fresh model.") + check(len(models) == (3 if scenario.get("publication_failure") else 2) and models[0] is not models[1], + "Resume did not create a fresh model.") if prior_messages: check("Remember this existing conversation." in histories[-1], "Earlier conversation context was lost.") check(state.read("owner", pending["id"])["status"] == "completed", "Request did not complete.") answer = json.loads(next(line[6:] for line in frames.splitlines() if '"done": true' in line)) - saved = config.cosmos_messages_container.read_item(answer["message_id"], "credential-chat") + saved_id = "credential_answer_" + hashlib.sha256(pending["id"].encode()).hexdigest() if shared_kind else answer["message_id"] + saved = config.cosmos_messages_container.read_item(saved_id, "credential-chat") check(saved["conversation_id"] == "credential-chat" and saved["content"] == "sim is RUNNING.", "Assistant answer was not persisted in the same conversation.") + if shared_kind: + shared_messages = list(config.cosmos_collaboration_messages_container.items.values()) + answers = [row for row in shared_messages if row.get("role") == "assistant"] + check(len(answers) == 1 and answers[0]["id"] == answer["message_id"], + "Continuation did not publish exactly one shared answer.") + check(answers[0]["reply_to_message_id"] == "shared-question", "Shared reply lost its question.") + check(answers[0]["agent_display_name"] == "Telemetry", "Shared reply lost agent attribution.") + check(answers[0]["metadata"]["source_thought_user_id"] == "owner", "Shared reply lost actor attribution.") + check(answer["conversation_id"] == visible_id, "Terminal frame used a hidden conversation.") + check("private-password-sentinel" not in json.dumps(shared_messages + shared_events), + "Shared storage/events received private credentials.") + check("credential_request" not in json.dumps(shared_messages + shared_events), + "Shared storage/events received a private prompt.") + check(pending["id"] not in json.dumps(shared_messages + shared_events), + "Shared storage/events received a private request identifier.") + empty = client.get("/api/action-credentials/pending?conversation_id=visible-chat") + check(empty.status_code == 200 and empty.json["requests"] == [], + "A completed shared action left a misleading pending banner.") check(sum(row.get("role") == "user" for row in config.cosmos_messages_container.items.values()) == 1 + bool(prior_messages), "Continuation reposted the question.") @@ -401,3 +527,17 @@ def test_fresh_reconstruction_rejects_real_changes(mutation): @pytest.mark.parametrize("scenario", [{"provider_status": 401}, {"persistence_failure": True}]) def test_resume_failure_cannot_report_success_or_replay(scenario): run_probe(PROBE, (json.dumps(scenario),), False) + + +@pytest.mark.parametrize("scenario", [ + {"shared": "personal", "scope": "personal"}, + {"shared": "personal", "scope": "personal", "participant": True, "streaming": True}, + {"shared": "group", "scope": "group"}, + {"shared": "group", "scope": "personal", "participant": True, "legacy_participant_state": True}, + {"shared": "personal", "scope": "personal", "publication_failure": True}, + {"shared": "personal", "scope": "personal", "participant": True, "mutation": "shared_membership"}, + {"shared": "personal", "scope": "personal", "mutation": "shared_question"}, + {"shared": "group", "scope": "group", "mutation": "shared_link"}, +]) +def test_shared_chat_private_prompt_and_exact_continuation(scenario): + run_probe(PROBE, (json.dumps(scenario),), False) diff --git a/functional_tests/test_action_credentials_shared_conversations.py b/functional_tests/test_action_credentials_shared_conversations.py new file mode 100644 index 000000000..52b4ef82f --- /dev/null +++ b/functional_tests/test_action_credentials_shared_conversations.py @@ -0,0 +1,283 @@ +# test_action_credentials_shared_conversations.py +"""Credential routing and private ownership across visible/source conversation IDs. + +Version: 0.261.348 +Implemented in: 0.261.348 + +Uses real credential storage, SDK point responses and Flask interaction routes. +Membership callbacks are explicit policy seams; real policies and real agents are +also exercised by test_action_credentials_chat_reconstruction.py. +""" + +from copy import deepcopy +from dataclasses import replace +from types import SimpleNamespace + +from azure.cosmos.exceptions import CosmosHttpResponseError +import pytest + +from test_action_personal_credentials import ( + CredentialContainer, challenge, entry, environment, +) + + +@pytest.fixture +def shared_environment(environment, monkeypatch): + from functions_action_credential_conversations import CredentialConversations + + env = environment + personal, shared, messages = CredentialContainer("id"), CredentialContainer("id"), CredentialContainer("conversation_id") + members = {"alice", "bob"} + personal.upsert_item({"id": "chat", "user_id": "alice"}) + shared.upsert_item({ + "id": "visible", "conversation_kind": "collaborative", "created_by_user_id": "alice", + "source_conversation_id": "source", "status": "active", + }) + personal.upsert_item({ + "id": "source", "user_id": "alice", "conversation_kind": "collaboration_source", + "collaboration_conversation_id": "visible", + }) + + def authorize_shared(actor, conversation): + if actor not in members or conversation.get("status") != "active": + raise PermissionError("Participation revoked.") + + def authorize_orchestration(conversation, actor): + authorize_shared(actor, conversation) + if actor != conversation["created_by_user_id"]: + raise PermissionError("Creator only.") + + resolver = CredentialConversations( + personal_container=personal, shared_container=shared, message_container=messages, + authorize_personal=env.credentials.authorize_conversation, + authorize_shared=authorize_shared, authorize_orchestration=authorize_orchestration, + ) + monkeypatch.setattr(env.credentials, "authorize_conversation", resolver.resolve) + monkeypatch.setattr(env.routes, "authorize_conversation", resolver.resolve) + monkeypatch.setattr(env.credentials, "_services", replace( + env.credentials.credential_services(), authorize_request_context=resolver.authorize_request, + )) + + def resolve_manifest(actor, reference): + if actor not in members or reference != env.credentials.manifest_reference(env.current): + raise PermissionError() + return deepcopy(env.current) + + monkeypatch.setattr(env.credentials, "authorized_manifest", resolve_manifest) + + def select_actor(actor): + env.auth.user = actor + with env.client.session_transaction() as browser: + browser["user"] = {"oid": actor} + message_id = "question-" + actor + messages.upsert_item({ + "id": message_id, "conversation_id": "visible", "message_kind": "ai_request", + "metadata": {"sender": {"user_id": actor}, "source_conversation_id": "source"}, + }) + monkeypatch.setattr(env.credentials, "actor_context", lambda: (actor, "source", None)) + env.descriptor.update( + visible_conversation_id="visible", collaboration_message_id=message_id, + ) + + select_actor("alice") + return SimpleNamespace( + env=env, resolver=resolver, personal=personal, shared=shared, messages=messages, + members=members, select_actor=select_actor, + ) + + +def test_empty_accessible_lists_are_read_only_for_personal_and_shared(shared_environment): + fixture, env = shared_environment, shared_environment.env + before = (fixture.personal.revision, fixture.shared.revision, fixture.messages.revision, env.store.revision) + for conversation_id in ("chat", "visible", "source"): + response = env.client.get("/api/action-credentials/pending", query_string={"conversation_id": conversation_id}) + assert response.status_code == 200 + assert response.json["requests"] == [] + assert response.headers["Cache-Control"] == "no-store" + fixture.select_actor("bob") + response = env.client.get("/api/action-credentials/pending?conversation_id=visible") + assert response.status_code == 200 and response.json["requests"] == [] + assert (fixture.personal.revision, fixture.shared.revision, env.store.revision) == (before[0], before[1], before[3]) + missing = env.client.get("/api/action-credentials/pending?conversation_id=missing") + assert missing.status_code == 404 + + +def test_two_participants_have_independent_private_requests_and_identities(shared_environment): + fixture, env = shared_environment, shared_environment.env + alice = challenge(env) + assert alice["conversation_id"] == "visible" and alice["conversation_kind"] == "collaborative" + fixture.select_actor("bob") + empty = env.client.get("/api/action-credentials/pending?conversation_id=visible") + assert empty.status_code == 200 and empty.json["requests"] == [] + denied = env.client.get("/api/action-credentials/" + alice["id"]) + assert denied.status_code == 404 + bob = challenge(env) + assert alice["id"] != bob["id"] + recovered = env.client.get("/api/action-credentials/pending?conversation_id=visible") + assert [item["id"] for item in recovered.json["requests"]] == [bob["id"]] + token = recovered.json["csrf_token"] + stolen = env.client.post("/api/action-credentials/" + alice["id"] + "/submit", json={ + "expected_etag": alice["etag"], "slots": [entry()], + }, headers={"X-CSRFToken": token}) + assert stolen.status_code == 404 and not env.identities.items + saved = env.client.post("/api/action-credentials/" + bob["id"] + "/submit", json={ + "expected_etag": bob["etag"], "slots": [entry(secret="BOB_PRIVATE_SECRET")], + }, headers={"X-CSRFToken": token}) + assert saved.status_code == 200 and saved.json["credential_request"]["status"] == "ready" + fixture.select_actor("alice") + recovered = env.client.get("/api/action-credentials/pending?conversation_id=visible") + assert [item["id"] for item in recovered.json["requests"]] == [alice["id"]] + assert recovered.json["requests"][0]["identities"] == [] + assert env.state.read("alice", alice["id"])["conversation_id"] == "source" + assert env.state.read("bob", bob["id"])["continuation"]["collaboration_message_id"] == "question-bob" + + +@pytest.mark.parametrize("change", ["membership", "status", "source", "question", "sender", "origin", "expiry"]) +def test_changed_access_or_unproven_origin_cannot_read_submit_or_continue(shared_environment, change): + fixture, env = shared_environment, shared_environment.env + dto = challenge(env) + path = "/api/action-credentials/" + dto["id"] + info = env.client.get(path) + token = info.json["csrf_token"] + if change == "membership": + fixture.members.remove("alice") + elif change == "status": + fixture.shared.items[("visible", "visible")]["status"] = "deleted" + elif change == "source": + fixture.personal.items[("source", "source")]["user_id"] = "other" + elif change == "question": + fixture.messages.items[("visible", "question-alice")]["metadata"]["is_deleted"] = True + elif change == "sender": + fixture.messages.items[("visible", "question-alice")]["metadata"]["sender"]["user_id"] = "bob" + elif change == "origin": + env.store.items[("alice", dto["id"])]["continuation"].pop("visible_conversation_id") + else: + env.store.items[("alice", dto["id"])]["expires_at"] = 0 + denied = env.client.get(path) + assert denied.status_code in {404, 409, 410} + for operation, body in ( + ("submit", {"expected_etag": dto["etag"], "slots": [entry()]}), + ("continue", {"expected_etag": dto["etag"]}), + ): + response = env.client.post(path + "/" + operation, json=body, headers={"X-CSRFToken": token}) + assert response.status_code in {404, 409, 410} + assert not env.identities.items + + +def test_shared_orchestration_backing_keeps_creator_only_policy(shared_environment): + fixture = shared_environment + fixture.personal.upsert_item({ + "id": "visible", "user_id": "alice", "conversation_kind": "collaboration_source", + "collaboration_conversation_id": "visible", + }) + item = { + "conversation_id": "visible", "continuation": {"mode": "orchestration"}, + } + context = fixture.resolver.authorize_request("alice", item) + assert context["credential_execution_ids"] == ("source", "visible") + with pytest.raises(LookupError): + fixture.resolver.authorize_request("bob", item) + fixture.members.remove("alice") + with pytest.raises(PermissionError): + fixture.resolver.authorize_request("alice", item) + + +def test_storage_failure_remains_explicit_and_safe(shared_environment, monkeypatch): + fixture, env = shared_environment, shared_environment.env + + def unavailable(*args, **kwargs): + raise CosmosHttpResponseError(status_code=503, message="PRIVATE_STORAGE_DETAIL") + + monkeypatch.setattr(fixture.shared, "read_item", unavailable) + response = env.client.get("/api/action-credentials/pending?conversation_id=visible") + assert response.status_code == 503 + assert response.json["code"] == "credentials_unavailable" + assert "PRIVATE_STORAGE_DETAIL" not in response.get_data(as_text=True) + assert response.headers["Cache-Control"] == "no-store" + + +def test_live_shared_stream_keeps_both_credential_event_formats_private(): + from test_orchestration_result_imports import run_probe + + run_probe(r''' +import json +import sys +from copy import deepcopy +from dataclasses import replace +from flask import Response, request +import pytest +sys.path[:0] = sys.argv[1:3] +from test_support.offline_bootstrap import offline_app_imports + +def check(value, message): + if not value: + raise AssertionError(message) + +with offline_app_imports(), pytest.MonkeyPatch.context() as patch: + import app + import functions_action_credentials as credentials + import functions_action_credential_state as state + import route_backend_collaboration as routes + from action_credential_contracts import ActionCredentialsRequired, credential_fingerprint + from functions_action_credential_conversations import CredentialConversations + from test_action_personal_credentials import CredentialContainer, manifest + from test_collaboration_group_agent_stream_fix import build_group_agent_stream_test_app + + web, registry, restore = build_group_agent_stream_test_app() + try: + actor = {"user_id": "alice", "display_name": "Actor", "email": ""} + patch.setattr(routes, "_get_current_collaboration_user", lambda: deepcopy(actor)) + visible, source = "shared-agent-conversation-001", "source-group-agent-conversation-001" + personal, shared, messages = CredentialContainer("id"), CredentialContainer("id"), CredentialContainer("conversation_id") + shared.upsert_item({"id": visible, "status": "active", "conversation_kind": "collaborative", + "created_by_user_id": "alice", "source_conversation_id": source}) + personal.upsert_item({"id": source, "user_id": "alice", "conversation_kind": "collaboration_source", + "collaboration_conversation_id": visible}) + resolver = CredentialConversations( + personal_container=personal, shared_container=shared, message_container=messages, + authorize_personal=lambda *args: None, authorize_shared=lambda *args: None, + authorize_orchestration=lambda *args: None, + ) + patch.setattr(credentials, "_services", replace( + credentials.credential_services(), authorize_conversation=resolver.resolve, + authorize_request_context=resolver.authorize_request, + )) + action = manifest() + patch.setattr(credentials, "authorized_manifest", lambda *args: action) + state.configure_action_credential_state(CredentialContainer("user_id")) + pending_ids = [] + for named in (False, True): + def internal_stream(): + payload = request.get_json() + question = payload["m365_collaboration_message_id"] + messages.upsert_item({"id": question, "conversation_id": visible, "message_kind": "ai_request", + "metadata": {"sender": actor, "source_conversation_id": source}}) + item = state.request_for( + "alice", source, credentials.manifest_reference(action), credential_fingerprint(action), + {"service": "api_key"}, reason="setup_required", + continuation={"mode": "chat", "checkpoint_id": "live-checkpoint-" + str(named), + "agent_reference": {"id": "agent"}, "visible_conversation_id": visible, + "collaboration_message_id": question}, + ) + pending_ids.append(item["id"]) + event = ActionCredentialsRequired(credentials.safe_request(item, action)).event + prefix = "event: action_credentials_required\n" if named else "" + return Response(prefix + "data: " + json.dumps(event) + "\n\n", mimetype="text/event-stream") + + web.view_functions["chat_stream_api"] = internal_stream + response = web.test_client().post( + "/api/collaboration/conversations/" + visible + "/stream", + json={"content": "Read mission telemetry."}, + ) + payloads = [json.loads(line[5:]) for line in response.get_data(as_text=True).splitlines() + if line.startswith("data:")] + event = next(value for value in payloads if value.get("type") == "action_credentials_required") + check(event["credential_request"]["conversation_id"] == visible, "Live prompt used a hidden source ID.") + check(event["credential_request"]["conversation_kind"] == "collaborative", "Live prompt lost its kind.") + cached = json.dumps(registry.events) + check("credential_request" not in cached and pending_ids[-1] not in cached, + "A private prompt entered another participant's event cache.") + finally: + restore() +print("PASS: live credential events remain private under the visible shared ID") +''', (), False) diff --git a/functional_tests/test_action_credentials_sk_continuation.py b/functional_tests/test_action_credentials_sk_continuation.py index 81aa19663..2f843ec3c 100644 --- a/functional_tests/test_action_credentials_sk_continuation.py +++ b/functional_tests/test_action_credentials_sk_continuation.py @@ -1,7 +1,7 @@ # test_action_credentials_sk_continuation.py """Real SK direct-action credential suspension and exact tool-boundary resume. -Version: 0.261.337 +Version: 0.261.348 Implemented in: 0.261.333 No model/provider network is used; the existing real SK scripted-provider harness proves completed tools are retained rather than replayed. @@ -29,9 +29,26 @@ @pytest.fixture(autouse=True) def continuation_services(runtime, monkeypatch): continuation = importlib.import_module("functions_action_credential_continuation") + credentials = importlib.import_module("functions_action_credentials") screening = importlib.import_module("content_screening.access") agents = importlib.import_module("functions_agent_delegation") settings = importlib.import_module("functions_settings") + + def authorize(user_id, conversation_id): + if (user_id, conversation_id) != ("actor", "conversation"): + raise PermissionError("Execution identity changed.") + return {"id": conversation_id, "user_id": user_id} + + def unused(*args, **kwargs): + raise AssertionError("The SK journal test must not perform identity CRUD.") + + monkeypatch.setattr(credentials, "_services", credentials.CredentialServices( + current_user_id=lambda: "actor", authorize_conversation=authorize, + resolve_manifest=lambda *args, **kwargs: runtime.manifest, + read_identity=unused, list_identities=lambda *args: [], + identity_supports_usage=unused, read_identity_auth=unused, + create_identity=unused, update_identity=unused, + )) monkeypatch.setattr(continuation, "_services", continuation.CredentialContinuationServices( collect_sources=lambda: screening.current_request_source_provenance(), authorize_source=lambda *args, **kwargs: screening.assert_document_available(*args, **kwargs), diff --git a/functional_tests/test_action_personal_credentials.py b/functional_tests/test_action_personal_credentials.py index a9d6fea6d..aa0f2cf66 100644 --- a/functional_tests/test_action_personal_credentials.py +++ b/functional_tests/test_action_personal_credentials.py @@ -1,7 +1,7 @@ # test_action_personal_credentials.py """Backend functional tests for actor-bound personal action credentials. -Version: 0.261.337 +Version: 0.261.348 Implemented in: 0.261.333 Exercises real storage owners, identity CRUD, resolver, schema and Flask routes @@ -105,6 +105,8 @@ def discard(names, *args, **kwargs): f"cosmos_{scope}_workspace_identities_container": identities for scope in ("personal", "group", "global", "public") }) + config.cosmos_collaboration_conversations_container = CredentialContainer("id") + config.cosmos_collaboration_messages_container = CredentialContainer("conversation_id") monkeypatch.setitem(sys.modules, "config", config) monkeypatch.setitem(sys.modules, "functions_keyvault", module( "functions_keyvault", SecretReturnType=SimpleNamespace(VALUE="value", TRIGGER="trigger", NAME="name"), @@ -157,11 +159,15 @@ def resolve(user_id, reference, **kwargs): )) monkeypatch.setitem(sys.modules, "route_backend_chats", module( "route_backend_chats", _authorize_personal_conversation_access=authorize, + cosmos_conversations_container=CredentialContainer("id"), )) continuation = importlib.import_module("functions_action_credential_continuation") monkeypatch.setattr(credentials, "_services", None) monkeypatch.setattr(continuation, "_services", None) importlib.import_module("functions_action_credential_runtime").configure_action_credential_runtime() + monkeypatch.setattr(credentials, "_services", replace( + credentials.credential_services(), authorize_request_context=None, + )) monkeypatch.delitem(sys.modules, "route_backend_action_credentials", raising=False) routes = importlib.import_module("route_backend_action_credentials") monkeypatch.setattr(routes, "authorize_conversation", authorize) diff --git a/functional_tests/test_v2_action_personal_credentials.mjs b/functional_tests/test_v2_action_personal_credentials.mjs index 88cd4d96d..52f339ecf 100644 --- a/functional_tests/test_v2_action_personal_credentials.mjs +++ b/functional_tests/test_v2_action_personal_credentials.mjs @@ -1,5 +1,5 @@ // test_v2_action_personal_credentials.mjs -// Version: 0.261.343 +// Version: 0.261.348 // Implemented in: 0.261.333 // Continuation recovery regressions added in: 0.261.343 // Exercises real authoring, safe DTO projection, credential transport, and stream waiting. @@ -85,6 +85,8 @@ const request = { }; const safe = readCredentialRequest(request); assert.equal(JSON.stringify(safe).includes('never-store'), false); +assert.equal(readCredentialRequest({ ...request, conversation_kind: 'collaborative' }).conversation_kind, 'collaborative'); +assert.throws(() => readCredentialRequest({ ...request, conversation_kind: 'source' }), /unsupported/); assert.throws(() => readCredentialRequest({ ...request, requirements: [{ slot: 'unknown', auth_type: 'api_key' }] }), /unsupported/); const windowBefore = globalThis.window; const fetchBefore = globalThis.fetch; @@ -128,7 +130,8 @@ try { assert.equal(refreshed.etag, 'etag-2'); assert.equal(calls.at(-1).options.cache, 'no-store'); assert.equal(calls.at(-1).options.method, 'GET', 'refresh must read the server, not submit credentials'); - for (const change of [{ id: 'foreign' }, { conversation_id: 'foreign' }, { run_id: 'foreign' }, { turn_id: 'foreign' }]) { + for (const change of [{ id: 'foreign' }, { conversation_id: 'foreign' }, { conversation_kind: 'collaborative' }, + { run_id: 'foreign' }, { turn_id: 'foreign' }]) { globalThis.fetch = async () => new Response(JSON.stringify({ credential_request: { ...safe, ...change }, csrf_token: 'wrong-work', }), { headers: { 'Content-Type': 'application/json' } }); diff --git a/ui_tests/test_v2_action_credentials.py b/ui_tests/test_v2_action_credentials.py index 7f24be036..8a33b6290 100644 --- a/ui_tests/test_v2_action_credentials.py +++ b/ui_tests/test_v2_action_credentials.py @@ -1,7 +1,7 @@ # test_v2_action_credentials.py """ Functional UI test for private credential cards in React V2. -Version: 0.261.343 +Version: 0.261.348 Implemented in: 0.261.333 Continuation recovery regressions added in: 0.261.343 @@ -40,7 +40,10 @@ (request) => { const H = window.OrchHarness; H.reset(); - H.stores.chat.useChatStore.setState({activeConversationId: request.conversation_id}); + H.stores.chat.useChatStore.setState({ + activeConversationId: request.conversation_id, + activeConversationKind: request.conversation_kind || 'personal', + }); H.stores.bootstrap.useBootstrapStore.setState({data: {user: {id: 'actor-1'}}}); window.__credentialCalls = []; window.fetch = async (url, options = {}) => { @@ -69,16 +72,20 @@ if (path.includes('/messages')) return new Response(JSON.stringify([]), {headers: {'Content-Type': 'application/json'}}); return new Response(JSON.stringify({}), {headers: {'Content-Type': 'application/json'}}); }; - H.mount('mount-a', 'CredentialRequestCards', {conversationId: request.conversation_id}); + H.mount('mount-a', 'CredentialRequestCards', { + conversationId: request.conversation_id, conversationKind: request.conversation_kind || 'personal', + }); } """ -def test_private_credentials_save_continue_and_navigation(): +@pytest.mark.parametrize("conversation_kind", ["personal", "collaborative"]) +def test_private_credentials_save_continue_and_navigation(conversation_kind): errors = [] + request = {**REQUEST, "conversation_kind": conversation_kind} try: with hb.harness_page(collect_errors=errors) as page: - page.evaluate(SETUP, REQUEST) + page.evaluate(SETUP, request) card = page.get_by_role("region", name="Personal action credentials") card.wait_for() card.get_by_label("Username", exact=True).fill("private-user-sentinel") @@ -109,15 +116,15 @@ def test_private_credentials_save_continue_and_navigation(): })""") assert "private-password-sentinel" not in captured assert "private-user-sentinel" not in captured - page.evaluate(SETUP, REQUEST) + page.evaluate(SETUP, request) card.wait_for() card.get_by_label("Password", exact=True).fill("discard-on-navigation") page.evaluate("""() => window.OrchHarness.mount('mount-a', 'CredentialRequestCards', - {conversationId: 'conversation-2'})""") + {conversationId: 'conversation-2', conversationKind: 'collaborative'})""") page.wait_for_function('() => document.querySelectorAll(\'input[type="password"]\').length === 0') assert page.locator('input[type="password"]').count() == 0 - page.evaluate("""() => window.OrchHarness.mount('mount-a', 'CredentialRequestCards', - {conversationId: 'conversation-1'})""") + page.evaluate("""(kind) => window.OrchHarness.mount('mount-a', 'CredentialRequestCards', + {conversationId: 'conversation-1', conversationKind: kind})""", conversation_kind) card.wait_for() assert card.get_by_label("Password", exact=True).input_value() == "" card.get_by_label("Personal identity", exact=True).select_option("identity-1") @@ -127,7 +134,7 @@ def test_private_credentials_save_continue_and_navigation(): page.wait_for_function("() => window.__credentialCalls.filter(call => call.path.endsWith('/submit')).length === 1") latest = page.evaluate("window.__credentialCalls.find(call => call.path.endsWith('/submit')).body") assert latest["slots"] == [{"slot": "proxy", "identity_id": "identity-1"}] - page.evaluate(SETUP, REQUEST) + page.evaluate(SETUP, request) card.wait_for() card.get_by_label("Password", exact=True).fill("discard-on-account-change") page.evaluate("""() => window.OrchHarness.stores.bootstrap.useBootstrapStore.setState({ @@ -140,6 +147,75 @@ def test_private_credentials_save_continue_and_navigation(): pytest.skip(str(exc)) +@pytest.mark.parametrize("status, copy", [ + (401, "Your session has expired."), + (404, "Your access to this conversation could not be confirmed."), + (503, "Unfinished action sign-in requests could not be checked."), +]) +def test_pending_load_errors_have_truthful_copy_and_single_flight_retry(status, copy): + errors = [] + with hb.harness_page(collect_errors=errors) as page: + page.evaluate("""(status) => { + const H = window.OrchHarness; + H.reset(); + H.stores.bootstrap.useBootstrapStore.setState({data: {user: {id: 'actor-1'}}}); + window.__pendingChecks = 0; + window.fetch = () => { + window.__pendingChecks += 1; + if (window.__pendingChecks === 1) return Promise.resolve(new Response('{}', {status})); + return new Promise(resolve => { window.__finishPendingCheck = () => resolve(new Response( + JSON.stringify({requests: [], csrf_token: 'fixture-csrf-token'}), + {headers: {'Content-Type': 'application/json'}} + )); }); + }; + H.mount('mount-a', 'CredentialRequestCards', {conversationId: 'shared', conversationKind: null}); + }""", status) + assert page.evaluate("window.__pendingChecks") == 0 + page.evaluate("""() => window.OrchHarness.mount('mount-a', 'CredentialRequestCards', + {conversationId: 'shared', conversationKind: 'collaborative'})""") + alert = page.get_by_role("alert") + alert.filter(has_text=copy).wait_for() + page.get_by_role("button", name="Reload requests", exact=True).click() + checking = page.get_by_role("button", name="Checking requests...", exact=True) + checking.wait_for() + assert checking.is_disabled() + checking.evaluate("(node) => { node.click(); node.click(); }") + assert page.evaluate("window.__pendingChecks") == 2 + assert alert.get_attribute("aria-busy") == "true" + page.evaluate("window.__finishPendingCheck()") + alert.wait_for(state="detached") + assert page.get_by_role("region", name="Personal action credentials").count() == 0 + assert not errors, errors + + +def test_navigation_discards_stale_pending_response(): + errors = [] + with hb.harness_page(collect_errors=errors) as page: + page.evaluate("""() => { + const H = window.OrchHarness; + H.reset(); + H.stores.bootstrap.useBootstrapStore.setState({data: {user: {id: 'actor-1'}}}); + window.__resolvePending = {}; + window.fetch = url => new Promise(resolve => { + const id = new URL(url, location.origin).searchParams.get('conversation_id'); + window.__resolvePending[id] = payload => resolve(new Response(JSON.stringify(payload), + {headers: {'Content-Type': 'application/json'}})); + }); + H.mount('mount-a', 'CredentialRequestCards', {conversationId: 'conversation-1', conversationKind: 'collaborative'}); + }""") + page.wait_for_function("() => Boolean(window.__resolvePending['conversation-1'])") + page.evaluate("""() => window.OrchHarness.mount('mount-a', 'CredentialRequestCards', + {conversationId: 'conversation-2', conversationKind: 'collaborative'})""") + page.wait_for_function("() => Boolean(window.__resolvePending['conversation-2'])") + page.evaluate("""request => { + window.__resolvePending['conversation-2']({requests: [], csrf_token: 'current-token'}); + window.__resolvePending['conversation-1']({requests: [request], csrf_token: 'stale-token'}); + }""", {**REQUEST, "conversation_kind": "collaborative"}) + page.wait_for_function("() => document.querySelectorAll('input[type=password]').length === 0") + assert page.get_by_role("region", name="Personal action credentials").count() == 0 + assert not errors, errors + + def test_action_source_controls_and_responsive_card(): errors = [] root = Path(__file__).resolve().parents[1] diff --git a/ui_tests/test_v2_action_credentials_backend.py b/ui_tests/test_v2_action_credentials_backend.py index 787c8a1eb..97e7a8fd3 100644 --- a/ui_tests/test_v2_action_credentials_backend.py +++ b/ui_tests/test_v2_action_credentials_backend.py @@ -1,7 +1,7 @@ # test_v2_action_credentials_backend.py """Real React Save and continue through Flask and freshly constructed SK agents. -Version: 0.261.346 +Version: 0.261.348 Implemented in: 0.261.345 Only Cosmos and external model/provider transport are offline. Browser fetch is @@ -69,6 +69,7 @@ def forward(route): url = urlsplit(request.url) if not (url.path.startswith("/api/action-credentials/") or url.path in { "/api/get_messages", "/api/msgraph/pending-actions", + "/api/collaboration/conversations/visible-chat/messages", }): errors.append(f"Unexpected application request: {url.path}") route.abort() @@ -98,8 +99,11 @@ def forward(route): H.stores.bootstrap.useBootstrapStore.setState({data: {user: {id: 'owner'}}}); H.stores.chat.useChatStore.setState({ activeConversationId: pending.conversation_id, messages: [userMessage], + activeConversationKind: pending.conversation_kind || 'personal', + }); + H.mount('mount-a', 'CredentialRequestCards', { + conversationId: pending.conversation_id, conversationKind: pending.conversation_kind || 'personal', }); - H.mount('mount-a', 'CredentialRequestCards', {conversationId: pending.conversation_id}); }""", {"pending": pending, "userMessage": user_message}) card = page.get_by_role("region", name="Personal action credentials") card.wait_for() @@ -133,7 +137,8 @@ def forward(route): @pytest.mark.ui -def test_save_continue_executes_tool_and_displays_same_conversation_answer(connect_options, monkeypatch): +@pytest.mark.parametrize("shared_kind", [None, "personal", "group"]) +def test_save_continue_executes_tool_and_displays_same_conversation_answer(connect_options, monkeypatch, shared_kind): # Run cold, real app imports without polluting other UI suites' module state. monkeypatch.setenv("SIMPLECHAT_CREDENTIAL_BROWSER_OPTIONS", json.dumps(connect_options)) prefix = """ @@ -146,7 +151,8 @@ def test_save_continue_executes_tool_and_displays_same_conversation_answer(conne from test_v2_action_credentials_backend import run_browser_probe run_browser_probe(json.loads(os.environ.pop("SIMPLECHAT_CREDENTIAL_BROWSER_OPTIONS"))) """ - run_probe(prefix, (), False) + scenario = {"shared": shared_kind, "scope": "personal", "participant": True} if shared_kind else {} + run_probe(prefix, (json.dumps(scenario),), False) def run_browser_probe(options):