diff --git a/application/single_app/config.py b/application/single_app/config.py index 32111db14..415d78fca 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -100,7 +100,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.217" +VERSION = "0.261.221" 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_agent_delegation.py b/application/single_app/functions_agent_delegation.py index 7d4d15257..505eda655 100644 --- a/application/single_app/functions_agent_delegation.py +++ b/application/single_app/functions_agent_delegation.py @@ -23,7 +23,7 @@ _MANIFEST_FIELDS = frozenset({ "id", "name", "displayName", "type", "description", "is_enabled", "identity_id", "endpoint", "auth", "metadata", "additionalFields", "user_id", "group_id", - "is_global", "is_group", "scope", "created_at", "created_by", "modified_at", + "is_global", "is_group", "scope", "scope_id", "created_at", "created_by", "modified_at", "modified_by", "updated_at", "last_updated", "_attachments", "_etag", "_rid", "_self", "_ts", "_delegation_scope_type", "_delegation_scope_id", }) diff --git a/application/single_app/functions_documents.py b/application/single_app/functions_documents.py index e1c47a4de..009623ea8 100644 --- a/application/single_app/functions_documents.py +++ b/application/single_app/functions_documents.py @@ -6822,6 +6822,8 @@ def upload_to_blob(temp_file_path, user_id, document_id, blob_filename, update_c return None try: + # The status write changes this record's etag, so it must precede the read that guards the final write. + update_callback(status=f"Uploading {blob_filename} to Blob Storage...") cosmos_container = _get_documents_container(group_id=group_id, public_workspace_id=public_workspace_id) current_document = cosmos_container.read_item(item=document_id, partition_key=document_id) storage_account_container_name = current_document.get("blob_container") or _get_blob_container_name( @@ -6875,8 +6877,6 @@ def upload_to_blob(temp_file_path, user_id, document_id, blob_filename, update_c metadata = {k: v for k, v in metadata.items() if v is not None} - update_callback(status=f"Uploading {blob_filename} to Blob Storage...") - with open(temp_file_path, "rb") as f: blob_client.upload_blob(f, overwrite=True, metadata=metadata) diff --git a/application/single_app/functions_simplechat_operations.py b/application/single_app/functions_simplechat_operations.py index 05a87844b..6f7afc1f3 100644 --- a/application/single_app/functions_simplechat_operations.py +++ b/application/single_app/functions_simplechat_operations.py @@ -2059,10 +2059,12 @@ def invite_group_conversation_members_for_current_user( if not is_group_collaboration_conversation(conversation_doc): raise LookupError(CONVERSATION_ACCESS_ERROR) + group_id = str(((conversation_doc.get("scope") or {}).get("group_id")) or "").strip() participants_to_add = _build_invited_participants( creator_user=current_user, participants=participants, participant_identifiers=participant_identifiers, + group_members=_group_member_summaries(find_group_by_id(group_id) if group_id else None), ) if not participants_to_add: raise ValueError("At least one participant identifier is required") @@ -2339,6 +2341,7 @@ def _build_invited_participants( creator_user: Dict[str, str], participants: Optional[Iterable[Dict[str, Any]]] = None, participant_identifiers: Any = None, + group_members: Optional[List[Dict[str, str]]] = None, ) -> List[Dict[str, str]]: invited_participants: List[Dict[str, str]] = [] seen_user_ids = {creator_user.get("user_id")} @@ -2354,7 +2357,9 @@ def _build_invited_participants( invited_participants.append(normalized_participant) for raw_identifier in _split_participant_identifiers(participant_identifiers): - resolved_user = resolve_directory_user(user_identifier=raw_identifier) + resolved_user = _match_group_member(group_members, raw_identifier) + if resolved_user is None: + resolved_user = resolve_directory_user(user_identifier=raw_identifier) normalized_participant = normalize_collaboration_user(resolved_user) if not normalized_participant: continue @@ -2367,6 +2372,42 @@ def _build_invited_participants( return invited_participants +def _group_member_summaries(group_doc: Optional[Dict[str, Any]]) -> List[Dict[str, str]]: + """The group's owner and members as collaboration user summaries, once each.""" + if not isinstance(group_doc, dict): + return [] + owner = group_doc.get("owner") if isinstance(group_doc.get("owner"), dict) else {} + raw_members = [ + {"userId": owner.get("id"), "displayName": owner.get("displayName"), "email": owner.get("email")}, + *(group_doc.get("users") or []), + ] + members: Dict[str, Dict[str, str]] = {} + for raw_member in raw_members: + summary = normalize_collaboration_user(raw_member) + if summary: + members.setdefault(summary["user_id"], summary) + return list(members.values()) + + +def _match_group_member(group_members: Optional[List[Dict[str, str]]], identifier: str) -> Optional[Dict[str, str]]: + """The one group member an invite identifier names by user ID, email or display name. + + Matching the group's own records needs no directory lookup, which a scheduled + workflow run cannot make. An identifier that names no member, or several, is left + to the directory. + """ + wanted = str(identifier or "").strip().lower() + matches = [ + member for member in group_members or [] + if wanted in { + str(member.get("user_id") or "").lower(), + str(member.get("email") or "").lower(), + str(member.get("display_name") or "").lower(), + } + ] + return matches[0] if len(matches) == 1 else None + + def _split_participant_identifiers(raw_identifiers: Any) -> List[str]: if raw_identifiers is None: return [] diff --git a/application/single_app/functions_workflow_runner.py b/application/single_app/functions_workflow_runner.py index d8da23154..5159bdb22 100644 --- a/application/single_app/functions_workflow_runner.py +++ b/application/single_app/functions_workflow_runner.py @@ -299,6 +299,7 @@ is_cross_format_compare_one_to_many_enabled, is_tabular_processing_enabled, normalize_model_endpoints, + read_user_settings_snapshot, resolve_model_endpoint_foundry_scope, ) from functions_tabular_parity_contract import ( @@ -5909,6 +5910,27 @@ def _get_workflow_runner_app(): return _workflow_runner_app +def _workflow_owner_identity(user_id): + """Return the owner's stored display name and email for a run without a signed-in session. + + Without them a background run acts as the bare object ID, which then shows as the name + on the groups and messages it creates and is written back over the stored display name. + """ + if not user_id: + return '', '' + try: + # The run acts as this owner, whose id comes from the stored workflow rather than a caller. + user_doc = read_user_settings_snapshot(user_id, allow_cross_user=True) + except AzureError as exc: + log_event( + '[WorkflowRunner] Owner profile unavailable for a background run', + extra={'user_id': user_id, 'error_type': type(exc).__name__}, + level=logging.WARNING, + ) + return '', '' + return str(user_doc.get('display_name') or '').strip(), str(user_doc.get('email') or '').strip() + + @contextmanager def _ensure_execution_context(user_id): created_context = None @@ -5920,13 +5942,14 @@ def _ensure_execution_context(user_id): reuse_existing = session_user_id == str(user_id or '').strip() if not reuse_existing: + display_name, email = _workflow_owner_identity(str(user_id or '').strip()) created_context = _get_workflow_runner_app().test_request_context('/api/internal/workflows/run') created_context.push() session['user'] = { 'oid': user_id, 'roles': ['User'], - 'preferred_username': '', - 'name': user_id, + 'preferred_username': email, + 'name': display_name or user_id, } try: diff --git a/application/single_app/model_endpoint_clients.py b/application/single_app/model_endpoint_clients.py index 6a2f1fafd..f584b124f 100644 --- a/application/single_app/model_endpoint_clients.py +++ b/application/single_app/model_endpoint_clients.py @@ -67,7 +67,7 @@ ANTHROPIC_MODEL_MARKERS = ("claude",) -OPENAI_REASONING_MODEL_PREFIXES = ("gpt-5", "o1", "o3", "o4") +OPENAI_REASONING_MODEL_PREFIXES = ("gpt-5", "gpt-6", "o1", "o3", "o4") MODEL_CONTEXT_MODE_SYSTEM = "system" MODEL_CONTEXT_MODE_FOLD_LATEST_USER = "fold_latest_user" @@ -111,6 +111,7 @@ def is_openai_reasoning_model(self) -> bool: return ( normalized_model_name.startswith(OPENAI_REASONING_MODEL_PREFIXES) or "gpt-5" in normalized_model_name + or "gpt-6" in normalized_model_name ) @property diff --git a/application/single_app/semantic_kernel_plugins/simplechat_plugin.py b/application/single_app/semantic_kernel_plugins/simplechat_plugin.py index ad28380c9..08cfacaf7 100644 --- a/application/single_app/semantic_kernel_plugins/simplechat_plugin.py +++ b/application/single_app/semantic_kernel_plugins/simplechat_plugin.py @@ -476,8 +476,8 @@ def create_personal_collaboration_conversation( ) @plugin_function_logger("SimpleChatPlugin") - # bac-check: ignore - add_group_member_for_current_user validates the current user's group role before mutation. - @kernel_function(description="Add a user directly to a group as the current user. user_identifier can be an email, user principal name, or user ID. If group_id is omitted, the active group is used.") + # bac-check: ignore - add_group_member_for_current_user validates the current user's group role before mutation; user_id only names the member to add, as the REST members route accepts. + @kernel_function(description="Add a user directly to a group as the current user. user_identifier can be an email, user principal name, or user ID. When you know the user's object ID, pass it as user_id with their email or display_name to add them without a directory lookup. If group_id is omitted, the active group is used.") def add_user_to_group( self, user_identifier: str = "", @@ -485,11 +485,13 @@ def add_user_to_group( role: str = "user", display_name: str = "", email: str = "", + user_id: str = "", ) -> dict: return self._execute_operation( "add_group_member", lambda: _with_agent_group_summary(add_group_member_for_current_user( group_id=group_id, + user_id=user_id, user_identifier=user_identifier, email=email, display_name=display_name, diff --git a/application/single_app/static/json/model_capabilities.json b/application/single_app/static/json/model_capabilities.json index 3dce7f655..c3c566a80 100644 --- a/application/single_app/static/json/model_capabilities.json +++ b/application/single_app/static/json/model_capabilities.json @@ -1,7 +1,7 @@ { "$schema": "https://simplechat.local/schemas/model-capabilities.schema.json", "schemaVersion": 4, - "lastUpdated": "2026-09-21", + "lastUpdated": "2026-10-02", "description": "SimpleChat model capability, reasoning-policy, embedding-policy, and token-capacity catalog. Published chat/generation limits retain independently verified evidence and hosting profiles. Embedding input, dimension, and batch policies are separate operation contracts and never supply a chat-token budget.", "capabilityFields": { "processesText": "Accepts text input.", @@ -51,7 +51,9 @@ "OpenAI model pages describe api.openai.com, not Azure deployment readiness. imageProfiles separates direct OpenAI, Azure OpenAI, and Foundry image operations. The application hosting cloud never establishes endpoint capabilities or availability.", "Azure documents Responses image generation with a separate GPT Image deployment. SimpleChat deliberately offers only dedicated image models on Azure/Foundry; the conditional Azure Responses integration is not enabled by publisher imageGenerationTool flags or administrator overrides.", "Government image-model availability is not established by the reviewed Government model table. Unknown is not unavailable, and a Government-hosted app may use an explicitly configured commercial endpoint without relabeling that endpoint as Government.", - "Image operation profiles describe the implemented single-image workflow. MAI and FLUX support source-image edits where stated, but their reviewed APIs do not document uploaded masks. Masking is not inferred from vision, tool calling, or inpainting prose." + "Image operation profiles describe the implemented single-image workflow. MAI and FLUX support source-image edits where stated, but their reviewed APIs do not document uploaded masks. Masking is not inferred from vision, tool calling, or inpainting prose.", + "GPT-6 Astra token capacities and reasoning efforts were reviewed on 2026-10-01, separately from the 2026-09-19 audit of the original 75 records. Azure Chat Completions rejected function tools for gpt-6-astra (2026-09-03) with the default and low efforts and rejected none. toolReasoningEfforts can't express a model with no tool-capable effort, so that observation is recorded in notes only.", + "GPT-6 Sol was reviewed on 2026-10-02. OpenAI documents that its Chat Completions function calling requires reasoning_effort none, and Azure behaved the same on 2026-10-01, so both providers carry a Chat Completions toolReasoningEfforts of none." ], "sources": [ { @@ -1354,6 +1356,41 @@ "title": "GPT-6 Astra model and supported Responses tools", "url": "https://developers.openai.com/api/docs/models/gpt-6-astra", "verifiedAt": "2026-09-16" + }, + { + "id": "openai-spec-gpt-6-astra", + "provider": "openai", + "title": "OpenAI gpt-6-astra exact model specification and reasoning efforts", + "url": "https://developers.openai.com/api/docs/models/gpt-6-astra", + "verifiedAt": "2026-10-01" + }, + { + "id": "azure-spec-gpt-6", + "provider": "azure", + "title": "Azure OpenAI exact model/version specifications: gpt-6", + "url": "https://learn.microsoft.com/en-us/azure/foundry/foundry-models/concepts/models-sold-directly-by-azure#gpt-6", + "verifiedAt": "2026-10-01" + }, + { + "id": "gpt-6-astra-deployed-contract", + "provider": "microsoft", + "title": "Observed GPT-6 Astra (2026-09-03) Azure Chat Completions rejections on 2026-10-01: none rejected with low, medium, high and xhigh listed as supported; function tools rejected with the default and low efforts", + "url": "https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning?view=foundry-classic", + "verifiedAt": "2026-10-01" + }, + { + "id": "openai-spec-gpt-6-sol", + "provider": "openai", + "title": "OpenAI gpt-6-sol exact model specification, reasoning efforts and Chat Completions function-calling rule", + "url": "https://developers.openai.com/api/docs/models/gpt-6-sol", + "verifiedAt": "2026-10-02" + }, + { + "id": "gpt-6-sol-deployed-contract", + "provider": "microsoft", + "title": "Observed GPT-6 Sol (2026-09-22) Azure Chat Completions on 2026-10-01: function tools rejected with the default effort and accepted with reasoning_effort none", + "url": "https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/reasoning?view=foundry-classic", + "verifiedAt": "2026-10-01" } ], "models": [ @@ -11004,18 +11041,318 @@ "generatesText": true, "processesImages": true, "generatesImages": false, - "imageGenerationTool": true, - "toolCalling": true + "processesAudio": false, + "generatesAudio": false, + "processesVideo": false, + "generatesVideo": false, + "processesBinaryFiles": true, + "optimizedForCoding": true, + "toolCalling": true, + "structuredOutput": true, + "supportsStreaming": true, + "reasoning": true, + "imageGenerationTool": true }, "imageProfiles": { "openai": "openai-responses" }, "notes": [ - "Image generation is a direct OpenAI Responses tool, not native image output or an Azure image deployment." + "Most capable GPT-6 tier; text and image input with text output.", + "Image generation is a direct OpenAI Responses tool, not native image output or an Azure image deployment.", + "OpenAI lists reasoning efforts low, medium, high, xhigh and max. none isn't supported, and Responses-only max is excluded from this Chat Completions policy.", + "Azure Chat Completions rejects function tools for this model unless reasoning_effort is none, which the model doesn't accept, so local agents with actions fail on it. See the azure profile note." ], "sourceIds": [ - "openai-gpt6-astra" - ] + "openai-gpt6-astra", + "openai-spec-gpt-6-astra", + "openai-reasoning", + "azure-spec-gpt-6", + "azure-reasoning", + "gpt-6-astra-deployed-contract" + ], + "contextWindow": 1050000, + "inputTokenLimit": 922000, + "outputTokenLimit": 128000, + "verifiedAliases": [], + "tokenLimitsApplicability": "text", + "outputTokenAccounting": "total_generation", + "tokenLimitEvidence": { + "contextWindow": { + "status": "verified", + "sourceIds": [ + "openai-spec-gpt-6-astra" + ], + "verifiedAt": "2026-10-01", + "note": "Direct OpenAI explicitly documents 1,050,000 tokens for the shared context of gpt-6-astra; this is a model capacity, not a requested response length." + }, + "inputTokenLimit": { + "status": "verified", + "sourceIds": [ + "openai-spec-gpt-6-astra" + ], + "verifiedAt": "2026-10-01", + "note": "Direct OpenAI explicitly documents 922,000 tokens for the independent maximum input of gpt-6-astra; this is a model capacity, not a requested response length." + }, + "outputTokenLimit": { + "status": "verified", + "sourceIds": [ + "openai-spec-gpt-6-astra" + ], + "verifiedAt": "2026-10-01", + "note": "Direct OpenAI explicitly documents 128,000 tokens for the maximum generation of gpt-6-astra; this is a model capacity, not a requested response length." + }, + "outputTokenAccounting": { + "status": "verified", + "sourceIds": [ + "openai-reasoning" + ], + "verifiedAt": "2026-10-01", + "note": "gpt-6-astra documents reasoning token support, and native OpenAI generation ceilings include reasoning tokens as well as visible output. Do not reserve reasoning twice or treat visible response length as total generation." + } + }, + "tokenLimitProfiles": [ + { + "id": "azure", + "provider": "azure", + "contextWindow": 1050000, + "inputTokenLimit": 922000, + "outputTokenLimit": 128000, + "outputTokenAccounting": "total_generation", + "tokenLimitEvidence": { + "contextWindow": { + "status": "verified", + "sourceIds": [ + "azure-spec-gpt-6" + ], + "verifiedAt": "2026-10-01", + "note": "Azure explicitly documents 1,050,000 tokens for the shared context of gpt-6-astra (2026-09-03); this is a model capacity, not a requested response length." + }, + "inputTokenLimit": { + "status": "verified", + "sourceIds": [ + "azure-spec-gpt-6" + ], + "verifiedAt": "2026-10-01", + "note": "Azure explicitly documents 922,000 tokens for the independent maximum input of gpt-6-astra (2026-09-03); this is a model capacity, not a requested response length." + }, + "outputTokenLimit": { + "status": "verified", + "sourceIds": [ + "azure-spec-gpt-6" + ], + "verifiedAt": "2026-10-01", + "note": "Azure explicitly documents 128,000 tokens for the maximum generation of gpt-6-astra (2026-09-03); this is a model capacity, not a requested response length." + }, + "outputTokenAccounting": { + "status": "verified", + "sourceIds": [ + "azure-reasoning" + ], + "verifiedAt": "2026-10-01", + "note": "Azure generation limits include reasoning and visible output; the actual request cap is separate from maximum model capacity." + } + }, + "notes": [ + "Azure Chat Completions rejected function tools for gpt-6-astra (2026-09-03) with the default and low reasoning efforts, and rejected reasoning_effort none. The provider error directs tool use to the Responses API, which local agents don't use. toolReasoningEfforts can't express a model with no tool-capable effort. Do not silently change the selected protocol or reasoning policy." + ] + } + ], + "reasoningPolicy": { + "status": "supported", + "efforts": [ + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "low", + "sourceIds": [ + "openai-spec-gpt-6-astra", + "gpt-6-astra-deployed-contract" + ] + } + }, + { + "id": "gpt-6-sol", + "provider": "openai", + "displayName": "GPT-6 Sol", + "aliases": [], + "family": "gpt-6", + "lifecycle": "current", + "capabilities": { + "processesText": true, + "generatesText": true, + "processesImages": true, + "generatesImages": false, + "processesAudio": false, + "generatesAudio": false, + "processesVideo": false, + "generatesVideo": false, + "processesBinaryFiles": true, + "optimizedForCoding": true, + "toolCalling": true, + "structuredOutput": true, + "supportsStreaming": true, + "reasoning": true, + "imageGenerationTool": true + }, + "imageProfiles": { + "openai": "openai-responses" + }, + "notes": [ + "GPT-6 tier for complex coding and agentic workflows; text and image input with text output.", + "Image generation is a direct OpenAI Responses tool, not native image output or an Azure image deployment.", + "OpenAI lists reasoning efforts none, low, medium, high, xhigh and max. Responses-only max is excluded from this Chat Completions policy.", + "Chat Completions supports function calling only with reasoning_effort none, on direct OpenAI and on Azure. With tools, SimpleChat sends none when no effort is set and rejects any other explicit effort." + ], + "sourceIds": [ + "openai-spec-gpt-6-sol", + "openai-reasoning", + "azure-spec-gpt-6", + "azure-reasoning", + "gpt-6-sol-deployed-contract" + ], + "contextWindow": 1050000, + "inputTokenLimit": 922000, + "outputTokenLimit": 128000, + "verifiedAliases": [], + "tokenLimitsApplicability": "text", + "outputTokenAccounting": "total_generation", + "tokenLimitEvidence": { + "contextWindow": { + "status": "verified", + "sourceIds": [ + "openai-spec-gpt-6-sol" + ], + "verifiedAt": "2026-10-02", + "note": "Direct OpenAI explicitly documents 1,050,000 tokens for the shared context of gpt-6-sol; this is a model capacity, not a requested response length." + }, + "inputTokenLimit": { + "status": "verified", + "sourceIds": [ + "openai-spec-gpt-6-sol" + ], + "verifiedAt": "2026-10-02", + "note": "Direct OpenAI explicitly documents 922,000 tokens for the independent maximum input of gpt-6-sol; this is a model capacity, not a requested response length." + }, + "outputTokenLimit": { + "status": "verified", + "sourceIds": [ + "openai-spec-gpt-6-sol" + ], + "verifiedAt": "2026-10-02", + "note": "Direct OpenAI explicitly documents 128,000 tokens for the maximum generation of gpt-6-sol; this is a model capacity, not a requested response length." + }, + "outputTokenAccounting": { + "status": "verified", + "sourceIds": [ + "openai-reasoning" + ], + "verifiedAt": "2026-10-02", + "note": "gpt-6-sol documents reasoning token support, and native OpenAI generation ceilings include reasoning tokens as well as visible output. Do not reserve reasoning twice or treat visible response length as total generation." + } + }, + "tokenLimitProfiles": [ + { + "id": "azure", + "provider": "azure", + "contextWindow": 1050000, + "inputTokenLimit": 922000, + "outputTokenLimit": 128000, + "outputTokenAccounting": "total_generation", + "tokenLimitEvidence": { + "contextWindow": { + "status": "verified", + "sourceIds": [ + "azure-spec-gpt-6" + ], + "verifiedAt": "2026-10-01", + "note": "Azure explicitly documents 1,050,000 tokens for the shared context of gpt-6-sol (2026-09-22); this is a model capacity, not a requested response length." + }, + "inputTokenLimit": { + "status": "verified", + "sourceIds": [ + "azure-spec-gpt-6" + ], + "verifiedAt": "2026-10-01", + "note": "Azure explicitly documents 922,000 tokens for the independent maximum input of gpt-6-sol (2026-09-22); this is a model capacity, not a requested response length." + }, + "outputTokenLimit": { + "status": "verified", + "sourceIds": [ + "azure-spec-gpt-6" + ], + "verifiedAt": "2026-10-01", + "note": "Azure explicitly documents 128,000 tokens for the maximum generation of gpt-6-sol (2026-09-22); this is a model capacity, not a requested response length." + }, + "outputTokenAccounting": { + "status": "verified", + "sourceIds": [ + "azure-reasoning" + ], + "verifiedAt": "2026-10-02", + "note": "Azure generation limits include reasoning and visible output; the actual request cap is separate from maximum model capacity." + } + } + }, + { + "id": "openai-chat-completions-tools", + "provider": "openai", + "protocol": "chat_completions", + "toolReasoningEfforts": [ + "none" + ], + "tokenLimitEvidence": { + "toolReasoningEfforts": { + "status": "verified", + "sourceIds": [ + "openai-spec-gpt-6-sol" + ], + "verifiedAt": "2026-10-02", + "note": "OpenAI documents that gpt-6-sol Chat Completions supports function calling only with reasoning_effort none. This does not establish a Responses restriction; never silently change effort or protocol." + } + }, + "notes": [ + "Validate the explicit request effort against toolReasoningEfforts when function tools are present. The string none is a supported value, not an omitted setting." + ] + }, + { + "id": "azure-chat-completions-tools", + "provider": "azure", + "protocol": "chat_completions", + "toolReasoningEfforts": [ + "none" + ], + "tokenLimitEvidence": { + "toolReasoningEfforts": { + "status": "verified", + "sourceIds": [ + "gpt-6-sol-deployed-contract", + "openai-spec-gpt-6-sol" + ], + "verifiedAt": "2026-10-02", + "note": "Azure Chat Completions rejected function tools for gpt-6-sol (2026-09-22) with the default effort and accepted them with an explicit reasoning_effort of none, matching the documented OpenAI rule. Never silently change effort or protocol." + } + }, + "notes": [ + "Validate the explicit request effort against toolReasoningEfforts when function tools are present. The string none is a supported value, not an omitted setting." + ] + } + ], + "reasoningPolicy": { + "status": "supported", + "efforts": [ + "none", + "low", + "medium", + "high", + "xhigh" + ], + "default_effort": "low", + "sourceIds": [ + "openai-spec-gpt-6-sol" + ] + } }, { "id": "gpt-image-2.5-flare", diff --git a/docs/explanation/fixes/CALL_AGENT_ACTION_SAVE_FIX.md b/docs/explanation/fixes/CALL_AGENT_ACTION_SAVE_FIX.md new file mode 100644 index 000000000..2709fee05 --- /dev/null +++ b/docs/explanation/fixes/CALL_AGENT_ACTION_SAVE_FIX.md @@ -0,0 +1,29 @@ +# Call Agent Action Save Fix + +Fixed in version: **0.261.217** + +## Issue + +Saving any Call agent action failed, whether personal, group, or global. Admins saw "Invalid plugin configuration." and the server logged `Validation error adding plugin: Call agent actions cannot override target credentials, connections, or configuration.` As a result, an agent could not delegate to another agent. + +## Root Cause + +Every save binds the action to its collection with `bind_action_origin` before validating it. Since action authorization was hardened, binding adds a server-owned `scope_id` field alongside `scope`, `is_global` and `is_group`. + +`validate_agent_action_manifest` accepts only the fields in its `_MANIFEST_FIELDS` allowlist, so it can reject anything a client might use to override the target's endpoint or credentials. The allowlist included `scope` but not `scope_id`, so every bound Call agent action was rejected as if it carried an override. + +## Technical Details + +Files modified: + +- `application/single_app/functions_agent_delegation.py` +- `functional_tests/test_agent_delegation_scoped_action_save.py` +- `application/single_app/config.py` + +`scope_id` is now on the allowlist. The value is always set by `bind_action_origin` from the authorized collection, so a client cannot choose it. All other extra fields are still rejected. + +## Validation + +`functional_tests/test_agent_delegation_scoped_action_save.py` binds a Call agent action with the real `bind_action_origin` for global, personal, and group scopes and validates it with the real `validate_agent_action_manifest`. It also checks that a bound manifest carrying another field, such as `api_key` or `base_url`, is still rejected. + +Without the fix, all three scope cases fail. diff --git a/docs/explanation/fixes/GPT6_ASTRA_MODEL_CATALOG_FIX.md b/docs/explanation/fixes/GPT6_ASTRA_MODEL_CATALOG_FIX.md new file mode 100644 index 000000000..6c51fd6cc --- /dev/null +++ b/docs/explanation/fixes/GPT6_ASTRA_MODEL_CATALOG_FIX.md @@ -0,0 +1,74 @@ +# GPT-6 Astra Model Catalog Fix + +Fixed in version: **0.261.220** + +Related issue: #1606 + +## Issue + +The model catalog described `gpt-6-astra` only through its image-generation tool. It had no token limits, reasoning policy or note about how the model handles function tools. That caused three problems: + +- **Token limits had to be entered by hand.** Every deployment needed limits set in Model Endpoints before it could use file evidence or a workflow token budget. Without them, workflows fell back to a small compatibility budget. +- **No reasoning policy.** With no policy, chat sent no reasoning effort for this model, so the model always used its default. Agents send their saved effort as is, so an agent saved with `none` was rejected: "Supported values are: 'low', 'medium', 'high', and 'xhigh'." +- **Nothing warned about function tools.** Azure Chat Completions rejects function tools for `gpt-6-astra` unless `reasoning_effort` is `none`, which the model also rejects. The catalog gave no sign of this, so agents with actions on `gpt-6-astra` failed only when they ran. + +## Root Cause + +`gpt-6-astra` was added as an operation-only record. Operation-only records can't carry token limits, and this one had no reasoning policy. + +The tool restriction isn't in the documentation either: + +- Azure's model table lists Chat Completions and "Functions, tools, and parallel tool calling" for GPT-6. It doesn't have the GPT-5.6 note that Chat Completions tools require `reasoning_effort: none`. +- OpenAI lists reasoning efforts low, medium, high, xhigh and max for `gpt-6-astra`. `none` isn't among them. + +## Technical Details + +Files modified: + +- `application/single_app/static/json/model_capabilities.json` +- `application/single_app/config.py` +- `functional_tests/test_model_catalog_rebase_integration.py` +- `functional_tests/test_gpt6_astra_catalog_record.py` (new) + +`gpt-6-astra` is now a token-evidenced record, reviewed on 2026-10-01 and kept separate from the 2026-09-19 audit of the original 75 records. It records: + +| Field | Value | Source | +|---|---|---| +| Shared context | 1,050,000 | OpenAI `gpt-6-astra` specification; Azure GPT-6 table for the `azure` profile | +| Maximum input | 922,000 | Same | +| Maximum output | 128,000 | Same | +| Output accounting | Total generation (reasoning plus visible output) | OpenAI and Azure reasoning guides | +| Reasoning efforts | low, medium, high, xhigh; fallback low | OpenAI specification and the observed Azure rejection | + +The capability flags match the documented features: text and image input with text output, streaming, structured outputs, function calling and reasoning. These flags are identical to the published feature list for `gpt-5.6-sol`. `max` isn't in the reasoning policy because SimpleChat's policy covers Chat Completions only. + +There are three new sources: `openai-spec-gpt-6-astra`, `azure-spec-gpt-6` and `gpt-6-astra-deployed-contract`. The last one records the Azure rejections observed on 2026-10-01 against model version 2026-09-03: + +| Reasoning effort | With function tools on Chat Completions | +|---|---| +| Not set (model default) | Rejected: "Function tools with reasoning_effort are not supported for gpt-6-astra in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'." | +| `low` | Rejected with the same error | +| `none` | Rejected: "'reasoning_effort' does not support 'none' with this model" | + +`toolReasoningEfforts` lists the efforts that may be used with tools. It can't express a model where no effort works, so the record doesn't declare one. The restriction is recorded in the model notes, the `azure` profile note and a coverage note. + +## Behavior After the Fix + +- Budgets for `gpt-6-astra` resolve to the verified limits for Azure and OpenAI, so file evidence and workflow budgets no longer need manual limits. Limits set in Model Endpoints still take precedence. +- Chat now sends the selected reasoning effort. If `none` or another unsupported effort is selected, chat sends `low` instead. Agents still send their saved effort as is, so don't save a `gpt-6-astra` agent with None. +- `gpt-6`, `gpt-6-sol` and `gpt-6-luna` don't inherit anything from this record. Suffixed deployment names such as `gpt-6-astra-eastus` get the reasoning policy, which matches by prefix, but not the token limits, which need the exact model name. +- Agents with actions on `gpt-6-astra` still fail on Azure Chat Completions. Local agents can't use the Responses API yet, and the runtime doesn't yet fail early with a clear message. #1606 tracks both. For agents with actions, use `gpt-6-sol`. From 0.261.221, SimpleChat sends Reasoning Effort None for it when tools are present; see [GPT6_SOL_CHAT_COMPLETIONS_TOOLS_FIX.md](GPT6_SOL_CHAT_COMPLETIONS_TOOLS_FIX.md). + +## Validation + +`functional_tests/test_gpt6_astra_catalog_record.py` checks these cases: + +- The catalog passes schema validation and the cross-record integrity checks. +- The OpenAI and Azure limits each cite their exact specification. +- No tool-effort constraint is invented. +- Budgets resolve for both providers and both protocols. +- The reasoning policy excludes `none` and corrects it to `low`. +- The capability flags keep the Responses image-generation tool. +- Sibling GPT-6 names don't inherit the record, and suffixed names get the reasoning policy but no token limits. + +`test_model_catalog_rebase_integration.py` now treats `gpt-6-astra` as a token-evidenced record with a reasoning policy, and registers the 2026-10-01 source review. The catalog, capability, reasoning, budget, image-generation and AI connection test files all pass. Four files that need `tiktoken` or live Azure credentials to import were skipped. diff --git a/docs/explanation/fixes/GPT6_REASONING_MODEL_PARAMETERS_FIX.md b/docs/explanation/fixes/GPT6_REASONING_MODEL_PARAMETERS_FIX.md new file mode 100644 index 000000000..19c49301d --- /dev/null +++ b/docs/explanation/fixes/GPT6_REASONING_MODEL_PARAMETERS_FIX.md @@ -0,0 +1,46 @@ +# GPT-6 Reasoning Model Parameters Fix + +Fixed in version: **0.261.219** + +## Issue + +Requests to GPT-6 deployments, such as `gpt-6-astra`, failed with a 400 whenever SimpleChat set a response length: + +> Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead. + +Workflows were the most visible case. Once a model's token limits are configured, the workflow budget sets a response length on every model call, so each workflow run that used a GPT-6 agent failed on its first call. Chat failed the same way when a Response Length was set. + +## Root Cause + +`ModelEndpointBehavior.is_openai_reasoning_model` in `model_endpoint_clients.py` recognized only `gpt-5`, `o1`, `o3` and `o4` names. The policy decides two things: + +- whether the response length is sent as `max_completion_tokens` or as `max_tokens`; +- whether a temperature is sent. + +GPT-6 deployments fell through to the non-reasoning defaults, which they reject. + +## Technical Details + +Files modified: + +- `application/single_app/model_endpoint_clients.py` +- `application/single_app/config.py` +- `functional_tests/test_gpt6_reasoning_model_parameters.py` (new) + +`gpt-6` joins `gpt-5` in both checks: as a name prefix, and as a family name anywhere in a deployment or display name, such as `N-gpt-6-astra` or `GPT 6 Astra`. Every caller that uses the shared policy follows it, including: + +- workflow budgets +- chat response length +- prompt variables +- content screening +- orchestration + +## Validation + +`functional_tests/test_gpt6_reasoning_model_parameters.py` runs the real policy and the workflow budget's parameter choice from source. It covers these cases: + +- GPT-6 deployment, alias and display names use `max_completion_tokens`, and are treated as reasoning models. +- GPT-5, o-series and GPT-4 models keep their parameters. +- The workflow budget sets `max_completion_tokens` for a GPT-6 model. + +Without the fix, the 4 GPT-6 cases and both workflow budget cases fail. `test_model_endpoint_protocol_inference.py` and `test_model_reasoning_capability_resolution.py` still pass. diff --git a/docs/explanation/fixes/GPT6_SOL_CHAT_COMPLETIONS_TOOLS_FIX.md b/docs/explanation/fixes/GPT6_SOL_CHAT_COMPLETIONS_TOOLS_FIX.md new file mode 100644 index 000000000..c4c3da035 --- /dev/null +++ b/docs/explanation/fixes/GPT6_SOL_CHAT_COMPLETIONS_TOOLS_FIX.md @@ -0,0 +1,66 @@ +# GPT-6 Sol Chat Completions Tools Fix + +Fixed in version: **0.261.221** + +Related issue: #1606 + +## Issue + +Agents with actions on `gpt-6-sol` failed unless their Reasoning Effort was set to None by hand. With no effort set, Azure rejected the request: + +> Function tools with reasoning_effort are not supported for gpt-6-sol in /v1/chat/completions. To use function tools, use /v1/responses or set reasoning_effort to 'none'. + +## Root Cause + +`gpt-6-sol` wasn't in the model catalog. SimpleChat already enforces this rule for GPT-5.6 through the catalog's `toolReasoningEfforts` field. With no record for `gpt-6-sol`, it sent the request with no effort, and the model applied its own default. + +OpenAI documents the rule on the `gpt-6-sol` model page: "Chat Completions supports function calling only with `reasoning_effort` set to `none`." Azure behaved the same on 2026-10-01: tools were rejected with the default effort and accepted with `none`. + +## Technical Details + +Files modified: + +- `application/single_app/static/json/model_capabilities.json` +- `application/single_app/config.py` +- `functional_tests/test_model_catalog_rebase_integration.py` +- `functional_tests/test_gpt6_astra_catalog_record.py` +- `functional_tests/test_gpt6_sol_catalog_record.py` (new) + +`gpt-6-sol` is now a token-evidenced record: + +| Field | Value | Source | +|---|---|---| +| Shared context | 1,050,000 | OpenAI `gpt-6-sol` specification; Azure GPT-6 table for the `azure` profile | +| Maximum input | 922,000 | Same | +| Maximum output | 128,000 | Same | +| Output accounting | Total generation (reasoning plus visible output) | OpenAI and Azure reasoning guides | +| Reasoning efforts | none, low, medium, high, xhigh; fallback low | OpenAI specification | +| Chat Completions tools | `toolReasoningEfforts: ["none"]` for `openai` and `azure` | OpenAI specification; Azure behavior observed on 2026-10-01 | + +The capability flags match the published feature list, which is the same as for `gpt-6-astra` and `gpt-5.6-sol`. That list includes the Responses image-generation tool. `max` isn't in the reasoning policy because SimpleChat's policy covers Chat Completions only. + +There are two new sources: `openai-spec-gpt-6-sol` and `gpt-6-sol-deployed-contract`. + +## Behavior After the Fix + +- An agent with tools on `gpt-6-sol` that has no Reasoning Effort set sends `none`, for Azure and for direct OpenAI. +- An explicit effort other than `none` on an agent with tools fails before the request with "This model's Chat Completions tools require Reasoning Effort None." The provider's 400 no longer appears. +- Without tools, every listed effort is sent as selected. +- The Responses protocol carries no tool restriction. +- Budgets use the verified limits, so file evidence and workflow budgets don't need manual limits. Limits set in Model Endpoints still take precedence. +- `gpt-6` and `gpt-6-luna` don't inherit anything from this record. + +## Validation + +`functional_tests/test_gpt6_sol_catalog_record.py` checks these cases: + +- The catalog passes schema validation and the cross-record integrity checks. +- The OpenAI and Azure limits each cite their exact specification. +- Both Chat Completions tool profiles require `none` and cite their evidence. +- Budgets carry the tool rule for Chat Completions only. +- Tools with no effort send `none`, and with `max_completion_tokens`. +- Tools with another explicit effort fail before the request; without tools, that effort is sent as selected. +- The reasoning policy and capability flags are correct. +- `gpt-6` and `gpt-6-luna` stay unknown. + +The catalog, capability, reasoning, budget and image tests pass. `test_ai_connections_capabilities.py` has the same 9 failures before and after this change. None of them involves GPT-6. diff --git a/docs/explanation/fixes/GROUP_DOCUMENT_BLOB_UPLOAD_ETAG_FIX.md b/docs/explanation/fixes/GROUP_DOCUMENT_BLOB_UPLOAD_ETAG_FIX.md new file mode 100644 index 000000000..dbc5048d5 --- /dev/null +++ b/docs/explanation/fixes/GROUP_DOCUMENT_BLOB_UPLOAD_ETAG_FIX.md @@ -0,0 +1,34 @@ +# Group Document Blob Upload Etag Fix + +Fixed in version: **0.261.217** + +## Issue + +With Enhanced Citations turned on, every document uploaded to a group workspace failed. The document row showed "Document processing failed. Contact an administrator if the problem persists." within a few seconds of the upload. That covered files added in the workspace and Word and Markdown documents saved to a group by an agent or workflow. Personal workspace uploads were not affected. + +The application log showed `Error uploading to Blob Storage.` after the blob itself had been written successfully, with `[ENHANCED_CITATIONS] Blob upload failed.` and error type `ScreeningConflictError`. + +## Root Cause + +`upload_to_blob` reads the document record, uploads the source file, and then writes the blob location back with `_upsert_document_and_sync_access_index`. Since group document projections were fenced against concurrent changes, that write for a group document requires the record's etag to match the one it read. + +Between the read and the write, `upload_to_blob` reported progress with `update_callback(status="Uploading ... to Blob Storage...")`. The callback updates the same Cosmos record, which issues a new etag, so the final write always saw a changed record and raised `ScreeningConflictError`. Personal documents without a screening marker use a plain upsert, so they were unaffected. + +## Technical Details + +Files modified: + +- `application/single_app/functions_documents.py` +- `functional_tests/test_group_document_blob_upload_etag.py` +- `application/single_app/config.py` + +`upload_to_blob` now posts the "Uploading ... to Blob Storage..." status before it reads the record. The read therefore returns the current etag, and the guarded write still rejects a change made by anyone else while the blob uploads. + +## Validation + +`functional_tests/test_group_document_blob_upload_etag.py` runs the real `upload_to_blob` and `_upsert_document_and_sync_access_index` definitions against an etag-tracking container: + +- Group and personal uploads complete after their own status update, and the record gets its blob path. +- A group upload still fails when another writer changes the record during the blob upload. + +Without the fix, the group upload test fails with `ScreeningConflictError`. diff --git a/docs/explanation/fixes/WORKFLOW_BACKGROUND_IDENTITY_FIX.md b/docs/explanation/fixes/WORKFLOW_BACKGROUND_IDENTITY_FIX.md new file mode 100644 index 000000000..b0ff5eda9 --- /dev/null +++ b/docs/explanation/fixes/WORKFLOW_BACKGROUND_IDENTITY_FIX.md @@ -0,0 +1,52 @@ +# Workflow Background Identity Fix + +Fixed in version: **0.261.218** + +## Issue + +A scheduled workflow run that used the SimpleChat action could not finish group and conversation work that a manual run of the same workflow completed: + +- `add_user_to_group` and `invite_group_conversation_members` failed with `Could not acquire access token`. +- Groups and conversation messages the run created showed the owner's object ID where their name should be. +- The run then wrote that object ID over the owner's stored display name. + +## Root Cause + +A scheduled run has no signed-in session, so `_ensure_execution_context` in the workflow runner creates one. That session held only the owner's object ID: `name` was the ID and `preferred_username` was empty. + +- Everything that reads the current user's name, such as group creation and conversation messages, recorded the ID. +- `get_user_settings` syncs the session's name into the stored profile when the session user is the profile's owner, so the ID replaced the stored display name. +- `resolve_directory_user` reaches Microsoft Graph with the signed-in user's delegated token unless it is given a user ID together with an email or display name. A scheduled run has no such token. The SimpleChat action's `add_user_to_group` could not pass a user ID, and group conversation invites always resolved each identifier through Graph, even for people who were already members of the group. + +A manual "Run now" was unaffected because it reuses the signed-in request's session. + +## Technical Details + +Files modified: + +- `application/single_app/functions_workflow_runner.py` +- `application/single_app/functions_simplechat_operations.py` +- `application/single_app/semantic_kernel_plugins/simplechat_plugin.py` +- `application/single_app/config.py` +- `functional_tests/test_workflow_background_identity.py` (new) +- `functional_tests/test_simplechat_agent_group_output.py` + +Changes: + +- **Run identity.** When the runner creates a session, it reads the owner's stored `display_name` and `email` with the read-only `read_user_settings_snapshot` and uses them for `name` and `preferred_username`. The owner ID comes from the stored workflow, not from a caller. If no profile is stored, or the read fails, the run keeps the previous bare identity; a failed read is logged as a warning. A run inside a request already signed in as the owner reuses that session unchanged, as before. Roles stay `['User']`. +- **Adding members.** `add_user_to_group` accepts `user_id`. Given with an email or display name, it resolves without a directory lookup, exactly as the REST `POST /api/groups//members` route already does. The acting user must still be the group's owner or an admin, which is checked again on the fresh group document. +- **Conversation invites.** `invite_group_conversation_members_for_current_user` first matches each identifier against the group's current owner and members, by user ID, email or display name, ignoring case. An identifier that matches no member, or more than one, falls back to the directory lookup as before. Only current group members can be invited to a group conversation, and that check is unchanged. + +## Validation + +`functional_tests/test_workflow_background_identity.py` runs the application's own definitions from source: + +- A scheduled run's session carries the owner's stored name and email, and the owner's settings are never written. +- An owner with no stored profile keeps the bare identity, and an unavailable settings store falls back to it with a warning. +- A run inside the owner's signed-in request keeps that session. A run inside another member's request acts as the owner. +- A user ID with an email resolves with Graph unavailable. +- Group conversation invites by email, ID or display name reach the right members with Graph unavailable. An ambiguous or non-member identifier is still sent to the directory. + +`functional_tests/test_simplechat_agent_group_output.py` checks that `add_user_to_group` passes `user_id` through to the group operation. + +Without the fix, every new check fails. Restoring the old bare-ID session values alone fails the two identity checks, and removing the cross-user allowance fails the member-started run check. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 4d17e9a75..fff52ed15 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,45 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.221)** + +#### Bug Fixes + +* **GPT-6 Sol Agents With Actions Send Reasoning Effort None** + * Agents with actions on `gpt-6-sol` failed unless their Reasoning Effort was set to None by hand. Chat Completions accepts function tools for this model only with `reasoning_effort: none`, and with no effort set the model applied its own default and rejected the tools. + * `gpt-6-sol` is now in the model catalog with a Chat Completions tool rule for Azure and direct OpenAI. An agent with tools and no effort set now sends `none`. Another explicit effort fails before the request with "This model's Chat Completions tools require Reasoning Effort None." instead of a provider error. + * The record also carries the verified limits (1,050,000 context, 922,000 input, 128,000 output, counting reasoning tokens) and the reasoning efforts none, low, medium, high and xhigh. + * (Ref: #1606, `model_capabilities.json`, [GPT-6 Sol Chat Completions Tools Fix](fixes/GPT6_SOL_CHAT_COMPLETIONS_TOOLS_FIX.md)) + +### **(v0.261.220)** + +#### Bug Fixes + +* **GPT-6 Astra Limits And Reasoning Efforts In The Model Catalog** + * `gpt-6-astra` now has verified token limits for Azure and direct OpenAI: 1,050,000 context, 922,000 input and 128,000 output, counting reasoning tokens. File evidence and workflow budgets work without limits entered in Model Endpoints, and limits entered there still take precedence. + * Its reasoning efforts are low, medium, high and xhigh. Chat now sends the selected effort; before, with no policy, it sent no effort and the model used its default. None and other unsupported selections are sent as Low. Agents still send their saved effort as is. + * Azure Chat Completions rejects function tools for this model at every effort it accepts, so agents with actions can't run on it yet. The catalog records this in its notes. #1606 tracks a Responses API path and an early, clear error. + * (Ref: #1606, `model_capabilities.json`, [GPT-6 Astra Model Catalog Fix](fixes/GPT6_ASTRA_MODEL_CATALOG_FIX.md)) + +### **(v0.261.219)** + +#### Bug Fixes + +* **GPT-6 Models Use Reasoning Model Request Parameters** + * Requests to GPT-6 deployments failed whenever a response length was set: "Unsupported parameter: 'max_tokens' is not supported with this model. Use 'max_completion_tokens' instead." Every workflow run on a GPT-6 agent failed on its first model call once token limits were configured. + * GPT-6 is now recognized alongside GPT-5, by name prefix and as a family name anywhere in a deployment or display name. Response length is sent as `max_completion_tokens`, and no temperature is sent. Workflow budgets, chat, prompt variables, content screening and orchestration all follow the shared policy. + * (Ref: #1605, `model_endpoint_clients.py`, [GPT-6 Reasoning Model Parameters Fix](fixes/GPT6_REASONING_MODEL_PARAMETERS_FIX.md)) + +### **(v0.261.218)** + +#### Bug Fixes + +* **Scheduled Workflow Runs Act As Their Owner** + * A scheduled run had no signed-in session, so the runner created one that held only the owner's object ID. Groups and conversation messages the run created showed that ID instead of the owner's name, and the run then wrote the ID over the owner's stored display name. The runner now uses the owner's stored display name and email. A manual run keeps the signed-in session, as before. + * The SimpleChat action's `add_user_to_group` now accepts `user_id`. Given with an email or display name, it adds the user without a directory lookup, as the REST members route already does. Scheduled runs have no delegated token for Microsoft Graph, so they couldn't add members before. + * Group conversation invites first match each identifier against the group's current owner and members, by user ID, email or display name. An identifier that matches no member, or more than one, still goes to the directory lookup. + * (Ref: `functions_workflow_runner.py`, `functions_simplechat_operations.py`, `simplechat_plugin.py`, [Workflow Background Identity Fix](fixes/WORKFLOW_BACKGROUND_IDENTITY_FIX.md), [SimpleChat action](../reference/actions/simplechat.md)) + ### **(v0.261.217)** #### New Features @@ -15,6 +54,18 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver * Admins can keep it out of plans by clearing **Read workflow results** in the orchestration Capabilities list. Group workflows, structured (version 3) runs and runs that saved an analysis can't be read from a plan, and a plan never waits for a run it starts to finish. * (Ref: #1546, #1543, `functions_orchestration_workflow_results.py`, `functions_orchestration_registry.py`, `functions_orchestration_workflow_context.py`, `functions_orchestration_planner.py`, `functions_orchestration_schema.py`, `functions_orchestration_execution.py`, `functions_orchestration_executor.py`, `functions_orchestration_composition.py`, `functions_orchestration_services.py`, `functions_orchestration_recovery.py`, `route_backend_orchestration.py`, [Chat Orchestration Workflow Results](features/CHAT_ORCHESTRATION_WORKFLOW_RESULTS.md), [Ask about workflow results](../guides/ask-about-workflow-results.md#ask-orchestrate-about-workflow-results)) +#### Bug Fixes + +* **Group Document Uploads With Enhanced Citations** + * With Enhanced Citations on, every document uploaded to a group workspace failed with "Document processing failed." That included Word and Markdown documents an agent or workflow saved to a group. The upload's own status update changed the document record's etag between the read and the guarded write, so the write always saw a conflict. + * The status is now posted before the record is read. A change made by anyone else during the upload is still rejected. + * (Ref: `functions_documents.py`, [Group Document Blob Upload Etag Fix](fixes/GROUP_DOCUMENT_BLOB_UPLOAD_ETAG_FIX.md)) + +* **Call Agent Actions Save Again** + * Saving any Call agent action, whether personal, group or global, failed with "Invalid plugin configuration." The manifest check rejected the server-owned `scope_id` field that binding to a collection adds, so agents couldn't delegate to other agents. + * `scope_id` is now accepted. The server always sets it from the authorized collection, and every other extra field is still rejected. + * (Ref: `functions_agent_delegation.py`, [Call Agent Action Save Fix](fixes/CALL_AGENT_ACTION_SAVE_FIX.md)) + #### User Interface Enhancements * **Saved Workflow Results In The V2 Run View** diff --git a/docs/reference/actions/simplechat.md b/docs/reference/actions/simplechat.md index 10b79ce8e..f7709b6c8 100644 --- a/docs/reference/actions/simplechat.md +++ b/docs/reference/actions/simplechat.md @@ -23,6 +23,15 @@ Use it for in-app automation. Do not enable capabilities beyond what the agent s - Uses signed-in user permissions; no external credentials. - Users also need access to the action through workspace or governance policy where applicable. +## In scheduled workflows + +A scheduled workflow run acts as the workflow's owner, with the name and email stored in their SimpleChat profile, but it has no signed-in session to look people up in the directory. Two things follow: + +- To add a group member from a scheduled run, give the person's object ID together with their email or display name. An email alone needs a directory lookup, which a scheduled run cannot make. +- Inviting people to a group conversation works by email, object ID or display name for anyone who is already a member of the group. + +A manual run uses the signed-in session, so neither limit applies to it. + ## Configuration overview Choose the default SimpleChat capabilities exposed to agents. diff --git a/functional_tests/test_agent_delegation_scoped_action_save.py b/functional_tests/test_agent_delegation_scoped_action_save.py new file mode 100644 index 000000000..f35ec304e --- /dev/null +++ b/functional_tests/test_agent_delegation_scoped_action_save.py @@ -0,0 +1,62 @@ +# test_agent_delegation_scoped_action_save.py +""" +Functional test for saving Call agent actions after scope binding. +Version: 0.261.217 +Implemented in: 0.261.217 + +Every save binds the action to its collection with bind_action_origin, which adds the +server-owned scope and scope_id fields, and only then validates the Call agent manifest. +The manifest allowlist must accept those bound fields, or no personal, group, or global +Call agent action can be saved, while still rejecting any other extra field. +""" + +import importlib.util + +import pytest + +from test_support.agent_delegation import APP_ROOT, delegation_environment, manifest, reference + + +@pytest.fixture +def environment(): + with delegation_environment() as value: + yield value + + +def load_action_manifest(): + spec = importlib.util.spec_from_file_location( + "_tested_scoped_functions_action_manifest", APP_ROOT / "functions_action_manifest.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize("scope_type,scope_id", [ + ("global", "global"), + ("personal", "actor"), + ("group", "group-1"), +]) +def test_scope_bound_call_agent_action_passes_manifest_validation(environment, scope_type, scope_id): + helper, _ = environment + target = reference("target-id", scope_type, scope_id) + bound = load_action_manifest().bind_action_origin(manifest(target), scope_type, scope_id) + assert bound["scope_id"] == scope_id + + result = helper.validate_agent_action_manifest(bound) + + assert result["additionalFields"]["target_agent"] == target + assert result["scope_id"] == scope_id + assert result["endpoint"] == "internal://agent" + assert result["auth"] == {"type": "user"} + + +@pytest.mark.parametrize("field,value", [ + ("api_key", "private-key"), + ("base_url", "https://override.invalid"), +]) +def test_scope_binding_does_not_admit_other_fields(environment, field, value): + helper, _ = environment + bound = load_action_manifest().bind_action_origin(manifest(**{field: value}), "global", "global") + with pytest.raises(ValueError): + helper.validate_agent_action_manifest(bound) diff --git a/functional_tests/test_gpt6_astra_catalog_record.py b/functional_tests/test_gpt6_astra_catalog_record.py new file mode 100644 index 000000000..f666808af --- /dev/null +++ b/functional_tests/test_gpt6_astra_catalog_record.py @@ -0,0 +1,180 @@ +# test_gpt6_astra_catalog_record.py +""" +Functional test for the GPT-6 Astra model catalog record. +Version: 0.261.221 +Implemented in: 0.261.220 + +Refs #1606. GPT-6 Astra moves from an operation-only record to a token-evidenced +record: 1,050,000 shared context, 922,000 input and 128,000 output for direct +OpenAI and Azure, total-generation accounting, and a Chat Completions reasoning +policy of low, medium, high and xhigh. Azure Chat Completions rejects function +tools for this model unless reasoning_effort is none, which the model rejects. +toolReasoningEfforts can't express a model with no tool-capable effort, so the +record must not declare one, and the restriction is recorded in notes instead. +No test fetches documentation, initializes application services or calls a model. +""" + +import json +import unittest +from itertools import product + +from test_model_capability_catalog_resolution import ( + CATALOG_PATH, + capabilities, + load_catalog_schema_validator, +) +from test_model_catalog_token_evidence import validate_catalog_integrity + + +ASTRA = "gpt-6-astra" +LIMITS = (1050000, 922000, 128000) +CAPACITY_FIELDS = ("contextWindow", "inputTokenLimit", "outputTokenLimit") +REVIEWED_AT = "2026-10-01" +EFFORTS = ["low", "medium", "high", "xhigh"] + + +class TestGpt6AstraCatalogRecord(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + cls.models = {record["id"]: record for record in cls.catalog["models"]} + cls.sources = {record["id"]: record for record in cls.catalog["sources"]} + cls.record = cls.models[ASTRA] + + def setUp(self): + capabilities.reset_model_capability_catalog_cache() + self.addCleanup(capabilities.reset_model_capability_catalog_cache) + + def azure_profile(self): + profiles = [ + profile for profile in self.record["tokenLimitProfiles"] + if profile["provider"] == "azure" + ] + self.assertEqual(len(profiles), 1) + self.assertNotIn("protocol", profiles[0]) + self.assertNotIn("modelVersions", profiles[0]) + return profiles[0] + + def test_catalog_validates_with_the_new_record(self): + load_catalog_schema_validator().validate(self.catalog) + validate_catalog_integrity(self.catalog) + self.assertGreaterEqual(self.catalog["lastUpdated"], REVIEWED_AT) + self.assertTrue(any( + ASTRA in note and "toolReasoningEfforts" in note + for note in self.catalog["coverageNotes"] + )) + + def test_native_limits_cite_the_exact_openai_specification(self): + record = self.record + self.assertEqual(tuple(record[field] for field in CAPACITY_FIELDS), LIMITS) + self.assertEqual(record["outputTokenAccounting"], "total_generation") + self.assertEqual(record["tokenLimitsApplicability"], "text") + self.assertEqual(record["verifiedAliases"], []) + source = self.sources["openai-spec-gpt-6-astra"] + self.assertEqual(source["provider"], "openai") + self.assertTrue(source["url"].endswith("/api/docs/models/gpt-6-astra")) + self.assertEqual(source["verifiedAt"], REVIEWED_AT) + for field in CAPACITY_FIELDS: + with self.subTest(field=field): + evidence = record["tokenLimitEvidence"][field] + self.assertEqual(evidence["status"], "verified") + self.assertEqual(evidence["sourceIds"], ["openai-spec-gpt-6-astra"]) + self.assertEqual(evidence["verifiedAt"], REVIEWED_AT) + self.assertIn(ASTRA, evidence["note"]) + + def test_azure_profile_limits_cite_the_gpt6_specification(self): + profile = self.azure_profile() + self.assertEqual(tuple(profile[field] for field in CAPACITY_FIELDS), LIMITS) + self.assertEqual(profile["outputTokenAccounting"], "total_generation") + source = self.sources["azure-spec-gpt-6"] + self.assertEqual(source["provider"], "azure") + self.assertTrue(source["url"].endswith("#gpt-6")) + for field in CAPACITY_FIELDS: + with self.subTest(field=field): + evidence = profile["tokenLimitEvidence"][field] + self.assertEqual(evidence["status"], "verified") + self.assertEqual(evidence["sourceIds"], ["azure-spec-gpt-6"]) + self.assertIn("2026-09-03", evidence["note"]) + + def test_tool_restriction_is_recorded_without_inventing_a_tool_effort(self): + self.assertNotIn("toolReasoningEfforts", self.record) + for profile in self.record["tokenLimitProfiles"]: + self.assertNotIn("toolReasoningEfforts", profile) + profile_notes = " ".join(self.azure_profile()["notes"]) + for phrase in ("Chat Completions", "function tools", "none", "Responses API"): + with self.subTest(phrase=phrase): + self.assertIn(phrase, profile_notes) + self.assertTrue(any( + "local agents with actions" in note for note in self.record["notes"] + )) + self.assertIn("gpt-6-astra-deployed-contract", self.record["sourceIds"]) + + def test_budgets_resolve_the_verified_limits_for_both_hosts(self): + for provider, protocol in product(("azure", "openai"), ("chat_completions", "responses")): + with self.subTest(provider=provider, protocol=protocol): + budget = capabilities.resolve_model_token_budget( + {"modelName": ASTRA}, + provider=provider, + protocol=protocol, + request_output_limit=4096, + ) + self.assertEqual(budget.model_id, ASTRA) + self.assertEqual( + (budget.context_window, budget.input_limit, budget.output_limit), LIMITS + ) + self.assertEqual(budget.output_accounting, "total_generation") + self.assertEqual(budget.tool_reasoning_efforts, ()) + self.assertEqual(budget.remaining_input(1000), 922000 - 1000) + + def test_reasoning_policy_excludes_none_and_responses_only_max(self): + policy = capabilities.resolve_model_reasoning_policy(ASTRA) + self.assertEqual(policy, {"status": "supported", "efforts": EFFORTS, "default_effort": "low"}) + self.assertIn("gpt-6-astra-deployed-contract", self.record["reasoningPolicy"]["sourceIds"]) + for effort in EFFORTS: + with self.subTest(effort=effort): + resolution = capabilities.resolve_model_reasoning_effort(ASTRA, effort) + self.assertEqual(resolution["effective_effort"], effort) + self.assertIsNone(resolution["adjustment_reason"]) + resolution = capabilities.resolve_model_reasoning_effort(ASTRA, "none") + self.assertEqual(resolution["effective_effort"], "low") + self.assertEqual(resolution["adjustment_reason"], "reasoning_effort_unsupported") + + def test_documented_capabilities_keep_the_responses_image_tool(self): + flags = capabilities.get_model_catalog_capabilities(ASTRA) + for name, expected in ( + ("processesText", True), ("generatesText", True), ("processesImages", True), + ("generatesImages", False), ("processesAudio", False), ("generatesAudio", False), + ("processesVideo", False), ("generatesVideo", False), ("toolCalling", True), + ("structuredOutput", True), ("supportsStreaming", True), ("reasoning", True), + ("imageGenerationTool", True), + ): + with self.subTest(capability=name): + self.assertIs(flags[name], expected) + self.assertEqual(flags["imageProfiles"], {"openai": "openai-responses"}) + self.assertTrue(capabilities.is_reasoning_model(ASTRA)) + + def test_siblings_inherit_nothing_and_suffixed_names_get_no_token_limits(self): + for name in ("gpt-6", "gpt-6-luna"): + with self.subTest(model=name): + self.assertEqual( + capabilities.resolve_model_reasoning_policy(name)["status"], "unknown" + ) + for name in ("gpt-6-astra-2026-09-03", "gpt-6-astra-eastus"): + with self.subTest(model=name): + self.assertEqual( + capabilities.resolve_model_reasoning_policy(name)["efforts"], EFFORTS + ) + for name in ("gpt-6", "gpt-6-luna", "gpt-6-astra-2026-09-03", "gpt-6-astra-eastus"): + with self.subTest(model=name): + budget = capabilities.resolve_model_token_budget( + {"modelName": name}, provider="azure", request_output_limit=4096, + ) + self.assertEqual( + (budget.context_window, budget.input_limit, budget.output_limit), + (None, None, None), + ) + self.assertEqual(budget.output_accounting, "unknown") + + +if __name__ == "__main__": + unittest.main() diff --git a/functional_tests/test_gpt6_reasoning_model_parameters.py b/functional_tests/test_gpt6_reasoning_model_parameters.py new file mode 100644 index 000000000..d30989ac0 --- /dev/null +++ b/functional_tests/test_gpt6_reasoning_model_parameters.py @@ -0,0 +1,64 @@ +# test_gpt6_reasoning_model_parameters.py +""" +Functional test for GPT-6 models using reasoning-model request parameters. +Version: 0.261.219 +Implemented in: 0.261.219 + +GPT-6 models reject `max_tokens` ("Use 'max_completion_tokens' instead"), as GPT-5 and +o-series models do. SimpleChat recognized only gpt-5, o1, o3 and o4 as reasoning models, +so any request that set a response length for a GPT-6 deployment failed with a 400. A +workflow sets one on every model call once the model's token limits are configured. + +The model behavior policy and the workflow budget's parameter choice are run unchanged +from source. +""" + +import sys +from collections.abc import Mapping + +import pytest + +from test_support.app_source import run_definitions + + +def load_behavior(): + namespace = run_definitions( + "model_endpoint_clients.py", {"OPENAI_REASONING_MODEL_PREFIXES", "ModelEndpointBehavior"}, {"Any": object}, + ) + return namespace["ModelEndpointBehavior"] + + +ModelEndpointBehavior = load_behavior() +response_parameter = run_definitions( + "functions_workflow_context.py", {"_response_parameter"}, + {"ModelEndpointBehavior": ModelEndpointBehavior, "Mapping": Mapping}, +)["_response_parameter"] + + +@pytest.mark.parametrize("name", ["gpt-6-astra", "gpt-6-sol", "N-gpt-6-astra", "astra-deployment GPT 6 Astra"]) +def test_gpt6_models_use_reasoning_parameters(name): + behavior = ModelEndpointBehavior("aoai", name) + assert behavior.is_openai_reasoning_model is True + assert behavior.response_length_parameter == "max_completion_tokens" + + +@pytest.mark.parametrize("name,parameter", [ + ("gpt-5.6-luna", "max_completion_tokens"), + ("o4-mini", "max_completion_tokens"), + ("gpt-4o", "max_tokens"), + ("gpt-4.1", "max_tokens"), +]) +def test_other_models_keep_their_parameter(name, parameter): + assert ModelEndpointBehavior("aoai", name).response_length_parameter == parameter + + +@pytest.mark.parametrize("audit,model", [ + ({"model_id": "gpt-6-astra"}, {}), + ({}, {"modelName": "gpt-6-astra", "deploymentName": "gpt-6-astra"}), +]) +def test_workflow_budget_sets_max_completion_tokens_for_gpt6(audit, model): + assert response_parameter(audit, model, "aoai") == "max_completion_tokens" + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_gpt6_sol_catalog_record.py b/functional_tests/test_gpt6_sol_catalog_record.py new file mode 100644 index 000000000..255d9ed22 --- /dev/null +++ b/functional_tests/test_gpt6_sol_catalog_record.py @@ -0,0 +1,154 @@ +# test_gpt6_sol_catalog_record.py +""" +Functional test for the GPT-6 Sol model catalog record. +Version: 0.261.221 +Implemented in: 0.261.221 + +Refs #1606. GPT-6 Sol's Chat Completions function calling works only with +reasoning_effort none: OpenAI documents it, and Azure behaved the same. The +record carries the verified limits and a Chat Completions toolReasoningEfforts of +none for both providers, so an agent with tools and no effort sends none, and an +explicit other effort fails before the request instead of with a provider 400. +No test fetches documentation, initializes application services or calls a model. +""" + +import json +import unittest +from itertools import product + +from semantic_kernel.connectors.ai.open_ai import OpenAIChatPromptExecutionSettings + +from test_model_capability_catalog_resolution import ( + CATALOG_PATH, + capabilities, + load_catalog_schema_validator, +) +from test_model_catalog_token_evidence import validate_catalog_integrity +from functions_model_budget_runtime import prepare_model_execution_settings + + +SOL = "gpt-6-sol" +LIMITS = (1050000, 922000, 128000) +CAPACITY_FIELDS = ("contextWindow", "inputTokenLimit", "outputTokenLimit") +EFFORTS = ["none", "low", "medium", "high", "xhigh"] + + +class TestGpt6SolCatalogRecord(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.catalog = json.loads(CATALOG_PATH.read_text(encoding="utf-8")) + cls.models = {record["id"]: record for record in cls.catalog["models"]} + cls.sources = {record["id"]: record for record in cls.catalog["sources"]} + cls.record = cls.models[SOL] + + def setUp(self): + capabilities.reset_model_capability_catalog_cache() + self.addCleanup(capabilities.reset_model_capability_catalog_cache) + + def budget(self, provider="azure", protocol="chat_completions"): + return capabilities.resolve_model_token_budget( + {"modelName": SOL}, provider=provider, protocol=protocol, request_output_limit=4096, + ) + + def test_catalog_validates_with_the_new_record(self): + load_catalog_schema_validator().validate(self.catalog) + validate_catalog_integrity(self.catalog) + self.assertTrue(any( + "GPT-6 Sol" in note and "toolReasoningEfforts" in note + for note in self.catalog["coverageNotes"] + )) + + def test_limits_cite_the_exact_openai_and_azure_specifications(self): + record = self.record + self.assertEqual(tuple(record[field] for field in CAPACITY_FIELDS), LIMITS) + self.assertEqual(record["outputTokenAccounting"], "total_generation") + self.assertTrue(self.sources["openai-spec-gpt-6-sol"]["url"].endswith("/api/docs/models/gpt-6-sol")) + azure = next(profile for profile in record["tokenLimitProfiles"] if profile["id"] == "azure") + self.assertEqual(tuple(azure[field] for field in CAPACITY_FIELDS), LIMITS) + for field in CAPACITY_FIELDS: + with self.subTest(field=field): + self.assertEqual(record["tokenLimitEvidence"][field]["sourceIds"], ["openai-spec-gpt-6-sol"]) + self.assertEqual(azure["tokenLimitEvidence"][field]["sourceIds"], ["azure-spec-gpt-6"]) + self.assertIn("2026-09-22", azure["tokenLimitEvidence"][field]["note"]) + + def test_chat_completions_tool_profiles_require_none_for_both_providers(self): + profiles = { + profile["provider"]: profile for profile in self.record["tokenLimitProfiles"] + if "toolReasoningEfforts" in profile + } + self.assertEqual(set(profiles), {"openai", "azure"}) + for provider, profile in profiles.items(): + with self.subTest(provider=provider): + self.assertEqual(profile["protocol"], "chat_completions") + self.assertNotIn("modelVersions", profile) + self.assertEqual(profile["toolReasoningEfforts"], ["none"]) + evidence = profile["tokenLimitEvidence"]["toolReasoningEfforts"] + self.assertEqual(evidence["status"], "verified") + self.assertIn("openai-spec-gpt-6-sol", evidence["sourceIds"]) + self.assertIn( + "gpt-6-sol-deployed-contract", + profiles["azure"]["tokenLimitEvidence"]["toolReasoningEfforts"]["sourceIds"], + ) + + def test_budgets_scope_the_tool_rule_to_chat_completions(self): + for provider, protocol in product(("azure", "openai"), ("chat_completions", "responses")): + with self.subTest(provider=provider, protocol=protocol): + budget = self.budget(provider, protocol) + self.assertEqual(budget.model_id, SOL) + self.assertEqual( + (budget.context_window, budget.input_limit, budget.output_limit), LIMITS + ) + self.assertEqual(budget.output_accounting, "total_generation") + expected = ("none",) if protocol == "chat_completions" else () + self.assertEqual(budget.tool_reasoning_efforts, expected) + + def test_tools_without_an_effort_send_none(self): + for provider in ("azure", "openai"): + with self.subTest(provider=provider): + prepared, _ = prepare_model_execution_settings( + OpenAIChatPromptExecutionSettings(), self.budget(provider), tools_enabled=True, + ) + serialized = prepared.prepare_settings_dict() + self.assertEqual(serialized["extra_body"]["reasoning_effort"], "none") + self.assertEqual(serialized["max_completion_tokens"], 4096) + self.assertNotIn("max_tokens", serialized) + + def test_tools_with_another_explicit_effort_fail_before_the_request(self): + settings = OpenAIChatPromptExecutionSettings(reasoning_effort="high") + with self.assertRaises(capabilities.ModelTokenBudgetError) as error: + prepare_model_execution_settings(settings, self.budget(), tools_enabled=True) + self.assertEqual(error.exception.code, "model_tool_configuration_invalid") + prepared, _ = prepare_model_execution_settings(settings, self.budget(), tools_enabled=False) + self.assertEqual(prepared.prepare_settings_dict()["extra_body"]["reasoning_effort"], "high") + + def test_reasoning_policy_and_capabilities(self): + policy = capabilities.resolve_model_reasoning_policy(SOL) + self.assertEqual(policy, {"status": "supported", "efforts": EFFORTS, "default_effort": "low"}) + resolution = capabilities.resolve_model_reasoning_effort(SOL, "none") + self.assertEqual(resolution["effective_effort"], "none") + self.assertIsNone(resolution["adjustment_reason"]) + flags = capabilities.get_model_catalog_capabilities(SOL) + for name, expected in ( + ("processesImages", True), ("generatesImages", False), ("toolCalling", True), + ("reasoning", True), ("supportsStreaming", True), ("imageGenerationTool", True), + ): + with self.subTest(capability=name): + self.assertIs(flags[name], expected) + self.assertEqual(flags["imageProfiles"], {"openai": "openai-responses"}) + self.assertTrue(capabilities.is_reasoning_model(SOL)) + + def test_luna_and_the_bare_family_stay_unknown(self): + for name in ("gpt-6", "gpt-6-luna"): + with self.subTest(model=name): + self.assertEqual( + capabilities.resolve_model_reasoning_policy(name)["status"], "unknown" + ) + budget = capabilities.resolve_model_token_budget( + {"modelName": name}, provider="azure", request_output_limit=4096, + ) + self.assertEqual(budget.tool_reasoning_efforts, ()) + self.assertIsNone(budget.context_window) + + +if __name__ == "__main__": + unittest.main() diff --git a/functional_tests/test_group_document_blob_upload_etag.py b/functional_tests/test_group_document_blob_upload_etag.py new file mode 100644 index 000000000..a79ae5a7b --- /dev/null +++ b/functional_tests/test_group_document_blob_upload_etag.py @@ -0,0 +1,161 @@ +# test_group_document_blob_upload_etag.py +""" +Functional test for group document blob uploads with Enhanced Citations. +Version: 0.261.217 +Implemented in: 0.261.217 + +Runs the real upload_to_blob and _upsert_document_and_sync_access_index definitions against +an etag-tracking container. The "Uploading..." status update rewrites the document record, so +the etag-guarded final write must read the record after that update. Before the fix, every +group upload with Enhanced Citations failed with ScreeningConflictError. The guard must still +reject a change made by anyone else while the blob uploads. +""" + +import ast +import logging +import uuid +from pathlib import Path +from types import SimpleNamespace + +import pytest + +APP_ROOT = Path(__file__).resolve().parents[1] / "application" / "single_app" +SCREENING_FIELD = "content_screening" + + +class ScreeningConflictError(Exception): + pass + + +class NotFoundError(Exception): + pass + + +class EtagContainer: + """Cosmos-like container whose every write issues a new etag.""" + + def __init__(self, *items): + self.items = {} + for item in items: + self._store(dict(item)) + + def _store(self, item): + item["_etag"] = uuid.uuid4().hex + self.items[item["id"]] = item + return dict(item) + + def read_item(self, item, partition_key): + if item not in self.items: + raise NotFoundError(item) + return dict(self.items[item]) + + def replace_item(self, item, body, etag=None, match_condition=None): + if etag is not None and self.items[item]["_etag"] != etag: + raise ScreeningConflictError("The record changed since it was read.") + return self._store(dict(body)) + + def upsert_item(self, body): + return self._store(dict(body)) + + def create_item(self, body): + return self._store(dict(body)) + + +class BlobStore: + def __init__(self, on_upload=None): + self.uploads = {} + self.on_upload = on_upload + + def get_blob_client(self, path): + store = self + + class BlobClient: + def upload_blob(self, data, overwrite, metadata): + store.uploads[path] = (data.read(), metadata) + if store.on_upload: + store.on_upload() + + return BlobClient() + + +def load_upload(container, blobs): + namespace = { + "logging": logging, + "current_extraction": lambda document_id: None, + "_get_documents_container": lambda group_id=None, public_workspace_id=None: container, + "_get_blob_container_name": lambda **kwargs: "group-documents" if kwargs.get("group_id") else "user-documents", + "build_current_blob_path": lambda filename, **kwargs: f"{kwargs.get('group_id') or kwargs.get('user_id')}/{filename}", + "_get_document_family_items_from_document": lambda document, **kwargs: [document], + "_document_revision_sort_key": lambda document: document.get("version", 0), + "_archive_previous_document_blob": lambda *args, **kwargs: None, + "_get_blob_service_client": object, + "_ensure_blob_container_ready": lambda client, name: blobs, + "CURRENT_ALIAS_BLOB_PATH_MODE": "current_alias", + "log_event": lambda *args, **kwargs: None, + "CosmosResourceNotFoundError": NotFoundError, + "assert_group_document_source_writable": lambda document: None, + "ScreeningConflictError": ScreeningConflictError, + "SCREENING_FIELD": SCREENING_FIELD, + "MatchConditions": SimpleNamespace(IfNotModified="if-not-modified"), + "sync_document_access_index_for_document_fail_open": lambda document, operation: {"success": True}, + "DocumentMutationPropagationError": RuntimeError, + } + names = {"upload_to_blob", "_upsert_document_and_sync_access_index"} + tree = ast.parse((APP_ROOT / "functions_documents.py").read_text(encoding="utf-8")) + nodes = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name in names] + assert {node.name for node in nodes} == names + exec(compile(ast.Module(body=nodes, type_ignores=[]), str(APP_ROOT / "functions_documents.py"), "exec"), namespace) + return namespace["upload_to_blob"] + + +def status_writer(container, document_id): + """Mirror update_document: each status change rewrites the record.""" + + def update_callback(**changes): + current = container.read_item(item=document_id, partition_key=document_id) + current.update(changes) + container.upsert_item(current) + + return update_callback + + +@pytest.fixture +def source_file(tmp_path): + path = tmp_path / "guide.md" + path.write_text("# Guide\n\nExercise content.\n", encoding="utf-8") + return path + + +@pytest.mark.parametrize("scope", ["group", "personal"]) +def test_upload_completes_after_its_own_status_update(source_file, scope): + document = {"id": "document", "user_id": "owner", "version": 1, "file_name": "guide.md", "status": "Queued for processing"} + group_id = "group-1" if scope == "group" else None + if group_id: + document["group_id"] = group_id + container = EtagContainer(document) + blobs = BlobStore() + upload_to_blob = load_upload(container, blobs) + + blob_path = upload_to_blob(str(source_file), "owner", "document", "guide.md", status_writer(container, "document"), group_id=group_id) + + saved = container.items["document"] + assert blob_path == f"{group_id or 'owner'}/guide.md" + assert saved["blob_path"] == blob_path and saved["source_file_available"] is True + assert saved["status"] == "Uploading guide.md to Blob Storage..." + assert blobs.uploads[blob_path][0].startswith(b"# Guide") + + +def test_group_upload_still_rejects_a_concurrent_change(source_file): + document = {"id": "document", "user_id": "owner", "group_id": "group-1", "version": 1, "file_name": "guide.md", "status": "Queued for processing"} + container = EtagContainer(document) + + def foreign_write(): + current = container.read_item(item="document", partition_key="document") + current["title"] = "Changed by another writer" + container.upsert_item(current) + + upload_to_blob = load_upload(container, BlobStore(on_upload=foreign_write)) + with pytest.raises(RuntimeError) as raised: + upload_to_blob(str(source_file), "owner", "document", "guide.md", status_writer(container, "document"), group_id="group-1") + assert isinstance(raised.value.__cause__, ScreeningConflictError) + assert "blob_path" not in container.items["document"] diff --git a/functional_tests/test_model_catalog_rebase_integration.py b/functional_tests/test_model_catalog_rebase_integration.py index 34d0c505b..afea90d16 100644 --- a/functional_tests/test_model_catalog_rebase_integration.py +++ b/functional_tests/test_model_catalog_rebase_integration.py @@ -1,8 +1,10 @@ # test_model_catalog_rebase_integration.py """ Offline regression tests for audited token and React V2 metadata coexistence. -Version: 0.261.122 +Version: 0.261.221 Implemented in: 0.261.122 +GPT-6 Astra token-evidenced record: 0.261.220 +GPT-6 Sol token-evidenced record: 0.261.221 Protect audited, qualitative-capability, reasoning-only, and embedding-policy records, provider-qualified image profiles, historical source reviews, and their @@ -35,7 +37,7 @@ OPERATION_ONLY_IDS = { "gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "o1", "o3", "o3-mini", "o4-mini", "gpt-image-2", "gpt-image-1.5", - "gpt-image-1", "gpt-image-1-mini", "dall-e-3", "gpt-6-astra", + "gpt-image-1", "gpt-image-1-mini", "dall-e-3", "gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "MAI-Image-2.5", "MAI-Image-2.5-Flash", "MAI-Image-2.5-Pro", "MAI-Image-2.6", "MAI-Image-2.6-Flash", @@ -50,7 +52,7 @@ "gpt-5.3-codex", "gpt-5.2-codex", "gpt-5.2", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5.1-codex-max", "gpt-5", "gpt-5-pro", "gpt-5-codex", "gpt-5-mini", "gpt-5-nano", - "gpt-4o", "gpt-4.1", "o1", "o3", "o3-mini", "o4-mini", + "gpt-4o", "gpt-4.1", "o1", "o3", "o3-mini", "o4-mini", "gpt-6-astra", "gpt-6-sol", } OPENAI_EMBEDDING_POLICY = { "max_input_tokens": 8192, @@ -122,7 +124,7 @@ "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5", "gpt-5.4", "gpt-5.4-pro", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.2", "gpt-5.1", "gpt-5", "gpt-5-nano", - "gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-nano", "o3", "gpt-6-astra", + "gpt-4o", "gpt-4o-mini", "gpt-4.1", "gpt-4.1-nano", "o3", "gpt-6-astra", "gpt-6-sol", } DIRECT_IMAGE_IDS = { "gpt-image-2", "gpt-image-1.5", "gpt-image-1", "gpt-image-1-mini", @@ -154,6 +156,13 @@ "image-profiles-mai", "image-profiles-flux", "image-profiles-government", "openai-gpt6-astra", }, + "2026-10-01": { + "openai-spec-gpt-6-astra", "azure-spec-gpt-6", "gpt-6-astra-deployed-contract", + "gpt-6-sol-deployed-contract", + }, + "2026-10-02": { + "openai-spec-gpt-6-sol", + }, } @@ -389,7 +398,7 @@ def test_historical_metadata_reviews_are_not_relabelled_as_token_audits(self): def test_metadata_only_variants_reject_token_fields(self): audited = self.models["gpt-5.6-sol"] for model_id, field in product( - ("gpt-4", "gpt-4o", "gpt-4.1-mini", "gpt-6-astra", "gpt-image-2", + ("gpt-4", "gpt-4o", "gpt-4.1-mini", "o3", "gpt-image-2", "text-embedding-ada-002", "embed-v-4-0"), TOKEN_METADATA_FIELDS, ): diff --git a/functional_tests/test_simplechat_agent_group_output.py b/functional_tests/test_simplechat_agent_group_output.py index 78c3c9829..9fbabfd28 100644 --- a/functional_tests/test_simplechat_agent_group_output.py +++ b/functional_tests/test_simplechat_agent_group_output.py @@ -167,12 +167,26 @@ def test_add_user_to_group_answers_the_summary_and_keeps_the_member(env): "member": MEMBER, "member_role": "user", "group": {"id": "group-1", "name": "Research", "status": "active"}, } assert env.calls == [("add_group_member_for_current_user", { - "group_id": "", "user_identifier": "nia.newcomer@example.test", "email": "", "display_name": "", + "group_id": "", "user_id": "", "user_identifier": "nia.newcomer@example.test", "email": "", "display_name": "", "role": "user", "default_group_id": "group-1", })] assert_private_values_absent(result) +def test_add_user_to_group_passes_a_known_object_id_through(env): + # Since 0.261.218 a scheduled workflow run, which has no token for a directory lookup, + # adds a member it already knows by object ID, as the REST members route does. + env.answers["add_group_member_for_current_user"] = { + "success": True, "message": "Member added", "group_id": "group-1", "group_name": "Research", + "member": MEMBER, "member_role": "user", "group": STORED_GROUP, + } + env.plugin.add_user_to_group(user_id="newcomer-1", email="nia.newcomer@example.test", display_name="Nia Newcomer") + assert env.calls == [("add_group_member_for_current_user", { + "group_id": "", "user_id": "newcomer-1", "user_identifier": "", "email": "nia.newcomer@example.test", + "display_name": "Nia Newcomer", "role": "user", "default_group_id": "group-1", + })] + + @pytest.mark.parametrize("old_status,message", [ ("active", "Marked group 'Research' as inactive."), ("inactive", "Group 'Research' is already inactive."), diff --git a/functional_tests/test_workflow_background_identity.py b/functional_tests/test_workflow_background_identity.py new file mode 100644 index 000000000..d73649c53 --- /dev/null +++ b/functional_tests/test_workflow_background_identity.py @@ -0,0 +1,247 @@ +# test_workflow_background_identity.py +""" +Functional test for scheduled workflow runs acting with their owner's identity. +Version: 0.261.218 +Implemented in: 0.261.218 + +A scheduled workflow run has no signed-in session, so the runner gave it one that held +only the owner's object ID. Groups and messages the run created showed that ID instead +of a name, and the ID was written over the owner's stored display name. The SimpleChat +action also could not add group members or invite them to a group conversation: both +looked people up in Microsoft Graph with a signed-in token that the run does not have. + +- The run's session carries the owner's stored display name and email, read without + writing the owner's settings. +- A user_id given with an email or display name resolves without a directory lookup, as + the REST members route already relies on. add_user_to_group now accepts one. +- A group conversation invite matches the group's current members by ID, email or + display name before it falls back to the directory. + +Each check runs the application's own definitions from source. Only storage, Microsoft +Graph and the collaboration store are modelled. +""" + +import copy +import logging +import re +import sys +from contextlib import contextmanager +from types import SimpleNamespace +from typing import Any, Dict, Iterable, List, Optional +from unittest.mock import patch + +import pytest +from azure.core.exceptions import AzureError +from azure.cosmos import exceptions as cosmos_exceptions +from flask import Flask, g, has_request_context, session + +from test_support.app_source import refusing_module_stub, run_definitions + + +OWNER_ID = "owner-1" +OWNER_EMAIL = "fiona.owner@example.test" +OWNER_PROFILE = {"id": OWNER_ID, "display_name": "Fiona Owner", "email": OWNER_EMAIL, "settings": {}} +OWNER_SESSION = {"oid": OWNER_ID, "roles": ["User"], "preferred_username": OWNER_EMAIL, "name": "Fiona Owner"} +BARE_SESSION = {"oid": OWNER_ID, "roles": ["User"], "preferred_username": "", "name": OWNER_ID} + +GROUP = { + "id": "group-1", + "name": "Regional Response Team", + "owner": {"id": OWNER_ID, "email": OWNER_EMAIL, "displayName": "Fiona Owner"}, + "users": [ + {"userId": OWNER_ID, "email": OWNER_EMAIL, "displayName": "Fiona Owner"}, + {"userId": "member-1", "email": "ada.admin@example.test", "displayName": "Ada Admin"}, + {"userId": "member-2", "email": "sam.lead@example.test", "displayName": "Sam Lead"}, + {"userId": "member-3", "email": "jo.editor@example.test", "displayName": "Jo Editor"}, + {"userId": "twin-1", "email": "alex.one@example.test", "displayName": "Alex Analyst"}, + {"userId": "twin-2", "email": "alex.two@example.test", "displayName": "Alex Analyst"}, + ], +} +CONVERSATION = { + "id": "conversation-1", + "chat_type": "group_multi_user", + "title": "Incident 1 Coordination", + "scope": {"group_id": "group-1", "group_name": "Regional Response Team"}, +} + + +class UserSettingsContainer: + """Stored user settings, which a background run may read but never write.""" + + def __init__(self, docs): + self.docs = docs + self.error = None + self.reads = [] + + def read_item(self, item, partition_key): + self.reads.append(item) + if self.error is not None: + raise self.error + if item not in self.docs: + raise cosmos_exceptions.CosmosResourceNotFoundError(status_code=404, message="Not found") + return copy.deepcopy(self.docs[item]) + + def upsert_item(self, *args, **kwargs): + raise AssertionError("A background run wrote the owner's settings") + + +def refuse_directory(*args, **kwargs): + # A scheduled run has no signed-in token, so every Graph directory call fails like this. + raise PermissionError("Could not acquire access token") + + +@pytest.fixture +def runner(): + logs = [] + container = UserSettingsContainer({OWNER_ID: copy.deepcopy(OWNER_PROFILE)}) + + def log_event(*args, **kwargs): + logs.append((args, kwargs)) + + settings = run_definitions("functions_settings.py", { + "USER_SETTINGS_REQUEST_CACHE_ATTR", "read_user_settings_snapshot", "_authorize_user_settings_access", + "_get_user_settings_request_cache", "_get_request_cached_user_settings", "_clone_user_settings_doc", + }, { + "copy": copy, "logging": logging, "g": g, "has_request_context": has_request_context, + "exceptions": cosmos_exceptions, "cosmos_user_settings_container": container, "log_event": log_event, + }) + namespace = run_definitions("functions_workflow_runner.py", { + "_workflow_runner_app", "_get_workflow_runner_app", "_workflow_owner_identity", "_ensure_execution_context", + }, { + "Flask": Flask, "SECRET_KEY": "test-secret", "session": session, "has_request_context": has_request_context, + "contextmanager": contextmanager, "AzureError": AzureError, "logging": logging, "log_event": log_event, + "read_user_settings_snapshot": settings["read_user_settings_snapshot"], + }) + # A cross-user settings read inside another member's request would consult the signed-in user. + authentication = refusing_module_stub( + "functions_authentication.py", "functions_authentication", + get_current_user_id=lambda: (session.get("user") or {}).get("oid"), + ) + with patch.dict(sys.modules, {"functions_authentication": authentication}): + yield SimpleNamespace(context=namespace["_ensure_execution_context"], container=container, logs=logs) + + +def session_user_inside(runner, user_id): + with runner.context(user_id): + return dict(session["user"]) + + +def signed_in_request(user): + app = Flask("signed_in_request") + app.secret_key = "test-secret" + context = app.test_request_context("/api/user/workflows/workflow-1/run") + context.push() + session["user"] = dict(user) + return context + + +def test_a_scheduled_run_acts_with_the_owners_stored_name_and_email(runner): + assert not has_request_context() + assert session_user_inside(runner, OWNER_ID) == OWNER_SESSION + assert runner.container.reads == [OWNER_ID] + assert not has_request_context() + + +def test_an_owner_without_a_stored_profile_keeps_the_bare_identity(runner): + runner.container.docs.clear() + assert session_user_inside(runner, OWNER_ID) == BARE_SESSION + + +def test_an_unavailable_settings_store_falls_back_to_the_bare_identity(runner): + runner.container.error = cosmos_exceptions.CosmosHttpResponseError(status_code=503, message="Unavailable") + assert session_user_inside(runner, OWNER_ID) == BARE_SESSION + assert [(args[0], kwargs.get("extra"), kwargs.get("level")) for args, kwargs in runner.logs] == [( + "[WorkflowRunner] Owner profile unavailable for a background run", + {"user_id": OWNER_ID, "error_type": "CosmosHttpResponseError"}, + logging.WARNING, + )] + + +def test_a_run_inside_the_owners_signed_in_request_keeps_that_session(runner): + signed_in = {"oid": OWNER_ID, "roles": ["User", "WorkflowUser"], "preferred_username": OWNER_EMAIL, "name": "Fiona"} + context = signed_in_request(signed_in) + try: + assert session_user_inside(runner, OWNER_ID) == signed_in + assert session["user"] == signed_in + finally: + context.pop() + assert runner.container.reads == [] + + +def test_a_run_inside_another_members_request_acts_as_the_owner(runner): + member = {"oid": "member-2", "roles": ["User"], "preferred_username": "max@example.test", "name": "Max Member"} + context = signed_in_request(member) + try: + assert session_user_inside(runner, OWNER_ID) == OWNER_SESSION + assert session["user"] == member + finally: + context.pop() + + +@pytest.fixture +def operations(): + invited = [] + models = run_definitions("collaboration_models.py", {"_clean_string", "normalize_collaboration_user"}, {}) + + def invite(conversation_id, owner_user_id, participants_to_add): + invited.append((conversation_id, owner_user_id, copy.deepcopy(participants_to_add))) + return copy.deepcopy(CONVERSATION), [ + { + "user_id": participant["user_id"], "user_display_name": participant["display_name"], + "user_email": participant["email"], "membership_status": "pending", + } + for participant in participants_to_add + ] + + namespace = run_definitions("functions_simplechat_operations.py", { + "CONVERSATION_ACCESS_ERROR", "invite_group_conversation_members_for_current_user", + "_build_invited_participants", "_group_member_summaries", "_match_group_member", + "_split_participant_identifiers", "resolve_directory_user", + }, { + "Any": Any, "Dict": Dict, "Iterable": Iterable, "List": List, "Optional": Optional, "re": re, + "normalize_collaboration_user": models["normalize_collaboration_user"], + "_require_collaboration_feature_enabled": lambda: {}, + "_require_current_user_info": lambda: {"userId": OWNER_ID, "email": OWNER_EMAIL, "displayName": "Fiona Owner"}, + "get_collaboration_conversation": lambda conversation_id: copy.deepcopy(CONVERSATION), + "CosmosResourceNotFoundError": cosmos_exceptions.CosmosResourceNotFoundError, + "is_group_collaboration_conversation": lambda doc: (doc or {}).get("chat_type") == "group_multi_user", + "find_group_by_id": lambda group_id: copy.deepcopy(GROUP) if group_id == "group-1" else None, + "invite_personal_collaboration_participants": invite, + "_get_directory_user_by_id": refuse_directory, + "_find_directory_users_by_email": refuse_directory, + "search_directory_users": refuse_directory, + }) + return SimpleNamespace(namespace=namespace, invited=invited) + + +def test_a_known_object_id_with_an_email_needs_no_directory_lookup(operations): + resolved = operations.namespace["resolve_directory_user"]( + user_id="member-1", email="ada.admin@example.test", display_name="Ada Admin", + ) + assert resolved == {"id": "member-1", "displayName": "Ada Admin", "email": "ada.admin@example.test"} + + +def test_invites_match_current_group_members_without_a_directory_lookup(operations): + result = operations.namespace["invite_group_conversation_members_for_current_user"]( + conversation_id="conversation-1", + participant_identifiers=f" ADA.ADMIN@example.test , member-2\nJo Editor; {OWNER_EMAIL}", + ) + assert operations.invited == [("conversation-1", OWNER_ID, [ + {"user_id": "member-1", "display_name": "Ada Admin", "email": "ada.admin@example.test"}, + {"user_id": "member-2", "display_name": "Sam Lead", "email": "sam.lead@example.test"}, + {"user_id": "member-3", "display_name": "Jo Editor", "email": "jo.editor@example.test"}, + ])] + assert [participant["user_id"] for participant in result["invited_participants"]] == ["member-1", "member-2", "member-3"] + + +@pytest.mark.parametrize("identifier", ["Alex Analyst", "outsider@example.test"]) +def test_an_identifier_naming_several_members_or_none_is_left_to_the_directory(operations, identifier): + with pytest.raises(PermissionError, match="Could not acquire access token"): + operations.namespace["invite_group_conversation_members_for_current_user"]( + conversation_id="conversation-1", participant_identifiers=identifier, + ) + assert operations.invited == [] + + +if __name__ == "__main__": + sys.exit(pytest.main([__file__, "-q"]))