From 1542da7605347039d1e1024efca55459726c150c Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 17 Oct 2026 17:13:31 -0400 Subject: [PATCH 1/2] Fix same-conversation credential reconstruction Preserve server-owned construction profiles across credential pauses without weakening execution guards. Add fresh-agent and browser regressions, safe diagnostics, and versioned documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../single_app/agent_delegation_runtime.py | 16 +- .../agent_logging_chat_completion.py | 5 +- application/single_app/config.py | 2 +- .../route_backend_action_credentials.py | 52 ++- .../single_app/semantic_kernel_loader.py | 101 ++--- .../features/PERSONAL_ACTION_CREDENTIALS.md | 25 +- ...TION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md | 110 +++++ docs/guides/personal-action-credentials.md | 10 +- ..._action_credentials_chat_reconstruction.py | 403 ++++++++++++++++++ .../test_v2_action_credentials_backend.py | 177 ++++++++ 10 files changed, 826 insertions(+), 75 deletions(-) create mode 100644 docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md create mode 100644 functional_tests/test_action_credentials_chat_reconstruction.py create mode 100644 ui_tests/test_v2_action_credentials_backend.py diff --git a/application/single_app/agent_delegation_runtime.py b/application/single_app/agent_delegation_runtime.py index 1053cdaf3..bbdaf0e85 100644 --- a/application/single_app/agent_delegation_runtime.py +++ b/application/single_app/agent_delegation_runtime.py @@ -153,19 +153,20 @@ async def close_agent_resources(kernel): log_event("[AGENT_DELEGATION] Client cleanup failed.", extra={"error_type": type(exc).__name__}) -def _build_local_agent(target, settings): +def _build_local_agent(target, settings, *, construction_profile="agent-core"): from semantic_kernel import Kernel from semantic_kernel_loader import load_agent_core_plugins, load_single_agent_for_kernel frame = current_agent_execution() capture_kwargs = {"invocation_capture": frame.invocation_capture} if frame.invocation_capture is not None else {} kernel = Kernel() - load_agent_core_plugins(kernel, settings, **capture_kwargs) + load_agent_core_plugins(kernel, settings, construction_profile=construction_profile, **capture_kwargs) kernel, agents = load_single_agent_for_kernel( kernel, deepcopy(target), settings, SimpleNamespace(), mode_label="group" if target.get("is_group") else "global" if target.get("is_global") else "per-user", group_scope_id=target.get("group_id"), execution_user_id=frame.identity.user_id, + construction_profile=construction_profile, **capture_kwargs, ) agent = (agents or {}).get(target.get("name")) @@ -576,10 +577,7 @@ async def _run(self, messages, *, streaming=False, **kwargs): invocation_retry_state = None retry_override = self._stream_retry_override selected = self.agent - descriptor = _continuation.get() or { - "mode": "chat", "checkpoint_id": self.credential_checkpoint_id, - "agent_reference": deepcopy(self.reference), - } + descriptor = _continuation.get() try: with agent_execution(frame) as frame: frame.budget.raise_authentication_requirement(frame.identity) @@ -606,6 +604,12 @@ async def _run(self, messages, *, streaming=False, **kwargs): kernel, selected = _build_local_agent(canonical, invocation_settings) if retry_override: invocation_retry_state = retry_override[1](selected, retry_override[0]) + if descriptor is None: + descriptor = { + "mode": "chat", "checkpoint_id": self.credential_checkpoint_id, + "agent_reference": deepcopy(self.reference), + "construction_profile": getattr(selected, "construction_profile", "agent-core"), + } if streaming: with agent_execution(frame), credential_continuation(descriptor): # Stream wrappers such as the M365 continuation journal set diff --git a/application/single_app/agent_logging_chat_completion.py b/application/single_app/agent_logging_chat_completion.py index 81d612515..89af25f4f 100644 --- a/application/single_app/agent_logging_chat_completion.py +++ b/application/single_app/agent_logging_chat_completion.py @@ -16,6 +16,7 @@ from functions_model_budget_runtime import prepare_model_execution_settings import datetime import re +from typing import Literal class LoggingChatCompletionAgent(ChatCompletionAgent): @@ -27,8 +28,9 @@ class LoggingChatCompletionAgent(ChatCompletionAgent): azure_endpoint: str | None = Field(default=None) api_version: str | None = Field(default=None) model_token_budget: ModelTokenBudget | None = Field(default=None, exclude=True) + construction_profile: Literal["agent-core", "per-user", "global"] = Field(default="agent-core", exclude=True) - def __init__(self, *args, display_name=None, default_agent=False, deployment_name=None, azure_endpoint=None, api_version=None, model_token_budget=None, **kwargs): + def __init__(self, *args, display_name=None, default_agent=False, deployment_name=None, azure_endpoint=None, api_version=None, model_token_budget=None, construction_profile="agent-core", **kwargs): # Remove these from kwargs so the base class doesn't see them kwargs.pop('display_name', None) kwargs.pop('default_agent', None) @@ -42,6 +44,7 @@ def __init__(self, *args, display_name=None, default_agent=False, deployment_nam self.azure_endpoint = azure_endpoint self.api_version = api_version self.model_token_budget = model_token_budget + self.construction_profile = construction_profile # tool_invocations is now properly declared as a Pydantic field def _merge_arguments(self, override_args): diff --git a/application/single_app/config.py b/application/single_app/config.py index 9841940bc..7119a0cfd 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.344" +VERSION = "0.261.345" 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/route_backend_action_credentials.py b/application/single_app/route_backend_action_credentials.py index 1a9157b94..78423a0b5 100644 --- a/application/single_app/route_backend_action_credentials.py +++ b/application/single_app/route_backend_action_credentials.py @@ -20,6 +20,22 @@ from swagger_wrapper import swagger_route, get_auth_security +CONTINUATION_FAILURE_CODES = frozenset({ + "credential_execution_changed", "credential_checkpoint_changed", "credential_request_changed", + "credential_continuation_unavailable", "credential_request_closed", "credentials_unavailable", + "action_credentials_required", "trusted_origin_required", +}) + + +def _log_continuation_failure(error, item, stage): + code = error.code if isinstance(error, ActionCredentialError) else None + log_event("[PLUGINS] Credential continuation stopped.", level=logging.WARNING, extra={ + **workflow_log_context(conversation_id=item["conversation_id"], turn_id=item["id"]), + "stage": stage, "error_type": type(error).__name__, + "failure_code": code if code in CONTINUATION_FAILURE_CODES else "credentials_unavailable", + }) + + def _csrf_token(): if not session.get("action_credentials_csrf_token"): session["action_credentials_csrf_token"] = secrets.token_urlsafe(32) @@ -145,16 +161,27 @@ def _chat_continue(item, user_id): import asyncio descriptor = {**item["continuation"], "resume": True} - checkpoint = state.read(user_id, descriptor["checkpoint_id"], record_type="credential_checkpoint") - if checkpoint["conversation_id"] != item["conversation_id"] or "root" not in checkpoint["value"]: - raise ActionCredentialError("credential_checkpoint_changed") - identity = capture_execution_identity(user_id, item["conversation_id"]) - settings = get_settings() - target = resolve_delegation_agent(descriptor["agent_reference"], user_id=user_id, settings=settings) - frame = AgentExecutionFrame(identity, target, DelegationBudget()) - # Reconstruct a new invocation-local kernel under the current actor's bridge. - with identity.bridge(target), agent_execution(frame): - kernel, agent = _build_local_agent(target, settings) + stage = "credential_checkpoint_validation" + try: + checkpoint = state.read(user_id, descriptor["checkpoint_id"], record_type="credential_checkpoint") + if checkpoint["conversation_id"] != item["conversation_id"] or "root" not in checkpoint["value"]: + raise ActionCredentialError("credential_checkpoint_changed") + stage = "credential_reconstruction" + identity = capture_execution_identity(user_id, item["conversation_id"]) + settings = get_settings() + target = resolve_delegation_agent(descriptor["agent_reference"], user_id=user_id, settings=settings) + frame = AgentExecutionFrame(identity, target, DelegationBudget()) + # Reconstruct a new invocation-local kernel under the current actor's bridge. + with identity.bridge(target), agent_execution(frame): + profile = descriptor.get("construction_profile") + # Existing requests retain their historical agent-core path; no backfill/replay. + kernel, agent = ( + _build_local_agent(target, settings) if profile is None + else _build_local_agent(target, settings, construction_profile=profile) + ) + except Exception as error: + _log_continuation_failure(error, item, stage) + raise async def execute(): with identity.bridge(target), agent_execution(frame), credential_continuation(descriptor): @@ -165,8 +192,10 @@ async def execute(): @stream_with_context def frames(): + stage = "credential_tool_resume" try: result = asyncio.run(execute()) + stage = "credential_response_persistence" authorize_conversation(user_id, item["conversation_id"]) message = { "id": uuid.uuid4().hex, "conversation_id": item["conversation_id"], "user_id": user_id, @@ -175,6 +204,7 @@ def frames(): "metadata": {"action_credential_continuation": True}, } saved = _persist_screened_assistant(message, user_id, settings=settings) + stage = "credential_continuation_finalization" _finish_request(user_id, item["id"], item["continuation_claim"], "completed") yield serialize_sse({"content": saved["content"]}) yield serialize_sse({ @@ -186,7 +216,7 @@ def frames(): yield serialize_sse(error.event) except Exception as error: _finish_request(user_id, item["id"], item["continuation_claim"], "failed") - log_event("[PLUGINS] Credential continuation stopped.", extra={"error_type": type(error).__name__}) + _log_continuation_failure(error, item, stage) yield serialize_sse({"error": "Saved work could not be continued. Refresh this conversation."}) return Response(frames(), mimetype="text/event-stream", headers={"Cache-Control": "no-store"}) diff --git a/application/single_app/semantic_kernel_loader.py b/application/single_app/semantic_kernel_loader.py index 793257a31..438f8bdb5 100644 --- a/application/single_app/semantic_kernel_loader.py +++ b/application/single_app/semantic_kernel_loader.py @@ -1254,8 +1254,8 @@ def load_core_plugins_only(kernel: Kernel, settings): log_event(f"[SK_LOADER] Failed to load Conversation Charts plugin: {e}", level=logging.WARNING) # =================== Semantic Kernel Initialization =================== -def load_agent_core_plugins(kernel: Kernel, settings, *, invocation_capture=None): - """Load the normal enabled agent core tools without any stored action manifests.""" +def load_agent_core_plugins(kernel: Kernel, settings, *, invocation_capture=None, construction_profile="agent-core"): + """Rebuild an entrypoint's existing tool surface, not the agent's storage scope.""" active = current_agent_execution() capture = invocation_capture if invocation_capture is not None else ( active.invocation_capture if active is not None else None @@ -1263,7 +1263,41 @@ def load_agent_core_plugins(kernel: Kernel, settings, *, invocation_capture=None if capture is not None: # Implicit search/embedding/core tools have no complete prepared-manifest proof. require_invocation_capture(capture).refuse() - load_plugins_for_kernel(kernel, [], settings, mode_label="agent-core") + if construction_profile == "per-user": + load_per_user_core_plugins(kernel, settings) + elif construction_profile == "global": + load_plugins_for_kernel(kernel, get_global_actions(return_type=SecretReturnType.NAME), settings, mode_label="global") + elif construction_profile == "agent-core": + load_plugins_for_kernel(kernel, [], settings, mode_label="agent-core") + else: + raise ValueError("Unsupported agent construction profile.") + + +def load_per_user_core_plugins(kernel, settings): + """Keep workspace-chat defaults identical before and after a credential pause.""" + for key, loader, label in ( + ("enable_time_plugin", load_time_plugin, "Time"), + ("enable_fact_memory_plugin", load_fact_memory_plugin, "Fact Memory"), + ("enable_math_plugin", load_math_plugin, "Math"), + ("enable_text_plugin", load_text_plugin, "Text"), + ("enable_http_plugin", load_http_plugin, "HTTP"), + ("enable_wait_plugin", load_wait_plugin, "Wait"), + ): + if settings.get(key, True): + loader(kernel) + log_event(f"[SK_LOADER] Loaded {label} plugin.", level=logging.INFO) + if settings.get("enable_default_embedding_model_plugin", True): + load_embedding_model_plugin(kernel, settings) + log_event("[SK_LOADER] Loaded Default Embedding Model plugin.", level=logging.INFO) + if is_tabular_processing_enabled(settings): + load_tabular_processing_plugin(kernel) + log_event("[SK_LOADER] Loaded Tabular Processing plugin.", level=logging.INFO) + try: + if load_chart_plugin(kernel): + log_event("[SK_LOADER] Loaded Conversation Charts plugin.", level=logging.INFO) + except Exception as error: + log_event("[SK_LOADER] Failed to load Conversation Charts plugin.", + extra={"error_type": type(error).__name__}, level=logging.WARNING) def initialize_semantic_kernel(user_id: str=None, redis_client=None): @@ -2103,7 +2137,7 @@ def _has_agent_delegation_actions(agent_cfg, settings, user_id): def load_single_agent_for_kernel( kernel, agent_cfg, settings, context_obj, redis_client=None, mode_label="global", - group_scope_id=None, execution_user_id=None, *, invocation_capture=None, + group_scope_id=None, execution_user_id=None, *, invocation_capture=None, construction_profile="agent-core", ): """ DRY helper to load a single agent (default agent) for the kernel. @@ -2163,6 +2197,7 @@ def load_single_agent_for_kernel( if delegation_enabled: kernel = Kernel() load_agent_core_plugins(kernel, settings) + construction_profile = "agent-core" agent_config = resolve_agent_config(agent_cfg, settings, group_scope_id=group_scope_id, execution_user_id=execution_user_id) agent_config["reasoning_effort"] = agent_cfg.get("reasoning_effort", agent_config.get("reasoning_effort")) if invocation_capture is not None and ( @@ -2478,6 +2513,7 @@ def capture_configuration(prepared_plugins): "azure_endpoint": agent_config["endpoint"], "api_version": agent_config["api_version"], "model_token_budget": model_budget, + "construction_profile": construction_profile, "arguments": ( captured_arguments if invocation_capture is not None else build_agent_budget_arguments(chat_service, agent_config, model_budget) @@ -3312,50 +3348,7 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie # DON'T load all user plugins globally - only load core plugins for per-user mode # Agent-specific plugins will be loaded by the agent itself based on actions_to_load # Only load core Semantic Kernel plugins here - if settings.get('enable_time_plugin', True): - load_time_plugin(kernel) - print(f"[SK_LOADER] Loaded Time plugin.") - log_event("[SK_LOADER] Loaded Time plugin.", level=logging.INFO) - - if settings.get('enable_fact_memory_plugin', True): - load_fact_memory_plugin(kernel) - print(f"[SK_LOADER] Loaded Fact Memory plugin.") - log_event("[SK_LOADER] Loaded Fact Memory plugin.", level=logging.INFO) - - if settings.get('enable_math_plugin', True): - load_math_plugin(kernel) - print(f"[SK_LOADER] Loaded Math plugin.") - log_event("[SK_LOADER] Loaded Math plugin.", level=logging.INFO) - - if settings.get('enable_text_plugin', True): - load_text_plugin(kernel) - print(f"[SK_LOADER] Loaded Text plugin.") - log_event("[SK_LOADER] Loaded Text plugin.", level=logging.INFO) - - if settings.get('enable_http_plugin', True): - load_http_plugin(kernel) - print(f"[SK_LOADER] Loaded HTTP plugin.") - log_event("[SK_LOADER] Loaded HTTP plugin.", level=logging.INFO) - - if settings.get('enable_wait_plugin', True): - load_wait_plugin(kernel) - print(f"[SK_LOADER] Loaded Wait plugin.") - log_event("[SK_LOADER] Loaded Wait plugin.", level=logging.INFO) - - if settings.get('enable_default_embedding_model_plugin', True): - load_embedding_model_plugin(kernel, settings) - print(f"[SK_LOADER] Loaded Default Embedding Model plugin.") - log_event("[SK_LOADER] Loaded Default Embedding Model plugin.", level=logging.INFO) - - if is_tabular_processing_enabled(settings): - load_tabular_processing_plugin(kernel) - log_event("[SK_LOADER] Loaded Tabular Processing plugin.", level=logging.INFO) - - try: - if load_chart_plugin(kernel): - log_event("[SK_LOADER] Loaded Conversation Charts plugin.", level=logging.INFO) - except Exception as e: - log_event(f"[SK_LOADER] Failed to load Conversation Charts plugin: {e}", level=logging.WARNING) + load_agent_core_plugins(kernel, settings, construction_profile="per-user") # Get selected agent from user settings (this still needs to be in user settings for UI state) user_settings = get_user_settings(user_id).get('settings', {}) @@ -3467,16 +3460,16 @@ def load_user_semantic_kernel(kernel: Kernel, settings, user_id: str, redis_clie agent_type = (agent_cfg.get('agent_type') or 'local').lower() agent_cfg['agent_type'] = agent_type if agent_type == 'local': - kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user", group_scope_id=effective_group_id) + kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user", group_scope_id=effective_group_id, construction_profile="per-user") elif agent_type in ('aifoundry', 'new_foundry', 'foundry_workflow'): - kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user", group_scope_id=effective_group_id) + kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user", group_scope_id=effective_group_id, construction_profile="per-user") else: log_event( f"[SK_LOADER] Unsupported agent_type '{agent_type}' for agent '{agent_cfg.get('name')}'. Defaulting to local path.", level=logging.WARNING, extra={'agent_type': agent_type, 'agent_name': agent_cfg.get('name')} ) - kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user", group_scope_id=effective_group_id) + kernel, agent_objs = load_single_agent_for_kernel(kernel, agent_cfg, settings, g, redis_client=redis_client, mode_label="per-user", group_scope_id=effective_group_id, construction_profile="per-user") print(f"[SK_LOADER] User {user_id} Agent loading completed. Agent objects: {type(agent_objs)} with {len(agent_objs) if agent_objs else 0} items") return kernel, agent_objs @@ -3775,16 +3768,16 @@ def load_semantic_kernel(kernel: Kernel, settings): agent_type = (global_selected_agent_cfg.get('agent_type') or 'local').lower() global_selected_agent_cfg['agent_type'] = agent_type if agent_type == 'local': - kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global") + kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global", construction_profile="global") elif agent_type in ('aifoundry', 'new_foundry', 'foundry_workflow'): - kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global") + kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global", construction_profile="global") else: log_event( f"[SK_LOADER] Unsupported agent_type '{agent_type}' for global agent '{global_selected_agent_cfg.get('name')}'. Defaulting to local path.", level=logging.WARNING, extra={'agent_type': agent_type, 'agent_name': global_selected_agent_cfg.get('name')} ) - kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global") + kernel, agent_objs = load_single_agent_for_kernel(kernel, global_selected_agent_cfg, settings, builtins, redis_client=None, mode_label="global", construction_profile="global") log_event(f"[SK_LOADER] load_single_agent_for_kernel returned agent_objs: {type(agent_objs)} with {len(agent_objs) if agent_objs else 0} agents", level=logging.INFO) else: log_event("[SK_LOADER] No global_selected_agent found. Proceeding in kernel-only mode.", level=logging.WARNING) diff --git a/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md b/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md index d679bf4a8..7a0a74c36 100644 --- a/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md +++ b/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md @@ -2,7 +2,7 @@ Fixed/Implemented in version: **0.261.333**. -Current application version: **0.261.337**. +Current application version: **0.261.345**. Related version update: `application/single_app/config.py`. @@ -110,6 +110,21 @@ under a server-owned, revision-fenced continuation. Completed operations are retained, not replayed by retrying the whole question. A credential request is not an approval for side effects. +Fresh chat reconstruction was fixed in **0.261.345**. New requests retain the +server-owned construction profile of the agent that actually paused. A global +agent selected in a per-user workspace therefore resumes with the workspace +tool inventory and defaults, not the different agent-core inventory. The +continuation constructs a fresh agent under the current actor and rechecks +configuration, model, tools, action access, and destination before executing. +It does not weaken the execution-signature guard or add Document Search to +workspace chat. + +**Save and continue** resolves the saved identity at the blocked tool boundary +and persists the assistant's answer in the original conversation. A successful +save alone is not a completed action. Existing failed requests are not migrated +or replayed. See the +[chat reconstruction fix](../fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md). + Yamcs is read-only. Future write-capable adapters must handle operation journaling and uncertain effects before acquiring equivalent continuation support. @@ -176,6 +191,14 @@ Semantic Kernel suspension and continuation, including nested agent execution. `ui_tests/test_classic_action_credentials.py` covers the classic action editor and private chat cards. +`functional_tests/test_action_credentials_chat_reconstruction.py` uses real +application loaders, authorization, identity storage, credential routes, +journals, and message persistence with offline Cosmos, model, and provider +transport. Unlike same-instance journal tests, it constructs a fresh agent for +continuation. `ui_tests/test_v2_action_credentials_backend.py` additionally +drives the bundled React card through real Flask routes and checks that the +provider result becomes the same conversation's assistant response. + Local validation for **0.261.333** passed 44 dedicated backend tests and 36 credential browser cases, plus the React type check and production build. That initial broader Cosmos contract run was non-green: five existing cold-import diff --git a/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md b/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md new file mode 100644 index 000000000..45070e42f --- /dev/null +++ b/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md @@ -0,0 +1,110 @@ +# Same-conversation action credential reconstruction fix + +Fixed/Implemented in version: **0.261.345**. + +Related version update: `application/single_app/config.py`. + +## Issue and root cause + +A personal action identity could save successfully, yet **Save and continue** +failed before the blocked tool executed. Starting another conversation used +the saved identity successfully. + +Initial per-user chat and continuation constructed different kernels. The +initial workspace loader omitted Document Search and defaulted Fact Memory to +enabled. Continuation always used the agent-core loader, whose inventory and +defaults differed. The retained execution-signature guard correctly rejected +this application-induced change. A global agent selected in a per-user +workspace still uses the per-user execution profile; storage scope alone +cannot identify the right reconstruction path. + +Production telemetry established successful credential submission followed by +an `ActionCredentialError` during ordinary chat continuation. It did not record +the exact error subcode. A local regression reproduced +`credential_execution_changed` through fresh reconstruction. + +## Implementation + +`semantic_kernel_loader.py` shares the existing core registrations through +explicit per-user, global, and agent-core construction profiles. Each retains +its previous capabilities and defaults. `LoggingChatCompletionAgent` carries +the actual profile as excluded server metadata, not model or browser data. + +`agent_delegation_runtime.py` records that profile in new chat continuation +descriptors after any delegation-driven reconstruction. The credential route +rebuilds a fresh invocation-local kernel using that profile, the exact selected +agent, current settings, and the current authenticated execution bridge. +Delegation and orchestration retain their existing agent-core defaults. + +The signature comparison, current authorization, destination confirmation, +private identity storage, conditional writes, and single-use continuation +claims remain in force. Credentials are read at the actual tool boundary. +Completed tool results stay in retained history; the original question is not +reposted. Completion still requires the resumed operation and screened +assistant response persistence. + +The route now records an allowlisted failure code, static execution stage, +exception type, and hashed request/conversation correlations through the +existing telemetry sanitizer. It does not log credential values, exception +messages, stored history, headers, or provider bodies. + +## Scope and usage + +For new requests, enter credentials or select a compatible saved personal +identity and choose **Save and continue**. The blocked action runs with that +identity and its response returns in the original conversation, including +conversations that already contain messages. + +No settings, routes, browser controls, or deployment migration are added. +Previously failed requests are not repaired, reset, migrated, or replayed. +Requests without profile metadata retain their historical reconstruction path. +Actual configuration/access changes continue to stop execution rather than +being treated as password failures. + +## Validation + +`functional_tests/test_action_credentials_chat_reconstruction.py` runs cold +real-module application probes with only external I/O doubled. It covers +fresh agents, actual Yamcs protobuf results and Basic authorization, +new/selected/replaced identities, global/personal/group execution, existing +conversation history, streaming, absent/explicit Fact Memory settings, prior +completed tools, duplicate continuation, and normal/optimized Python. + +Negative cases retain the change guard for instructions, model, tool inventory, +destination, conversation ownership, and group membership. Authentication +rejection must re-challenge; response-persistence failure cannot report success +or automatically replay the operation. Secret sentinels must remain absent +from SSE, telemetry, model history, and private continuation metadata. + +`ui_tests/test_v2_action_credentials_backend.py` drives the real React card +against actual Flask credential/message routes and a scripted provider. It +requires one save, one continuation, no replacement chat request, cleared +credential inputs, and the persisted assistant answer in the original chat. +Existing credential, orchestration, bootstrap, and browser regressions remain +applicable. + +The focused backend run passed **154 tests**, including all **16** new +reconstruction scenarios. The React credential suite passed **23 browser +tests**, including the real-backend save-to-answer scenario. Route policy +coverage passed **30 tests**; documentation checks passed **7 coverage** and +**6 site-quality** checks. The Node credential transport checks also passed. +The network-blocked Cosmos gate passed **1,079 tests and 244 subtests**, with +**1,066 reviewed owners, 409 doubles, and zero review gaps**. + +The broader plugin run has one unchanged baseline failure: +`test_logged_core_plugins.py` records no thought events for its synthetic +tracker. It fails identically in isolation with unchanged application source. +Nine AST-extraction failures in `test_agent_delegation_entrypoints.py` were +reproduced identically against unchanged baseline source. Those fixtures omit +dependencies referenced by the extracted functions; this fix does not change +them or claim these broader suites are green. + +## Before and after + +Previously, a successful save could be followed by rejection of the +application's own different reconstruction, despite valid credentials. +Matching construction now lets unchanged, authorized work resume while +preserving rejection of genuine configuration or access changes. + +See [personal action credentials](../features/PERSONAL_ACTION_CREDENTIALS.md) +and the [user guide](../../guides/personal-action-credentials.md). diff --git a/docs/guides/personal-action-credentials.md b/docs/guides/personal-action-credentials.md index b7176275e..5fc389906 100644 --- a/docs/guides/personal-action-credentials.md +++ b/docs/guides/personal-action-credentials.md @@ -4,7 +4,7 @@ title: "Use your own credentials with a shared action" description: "Provide a private personal identity when a shared Yamcs action needs your sign-in." section: "Guides" audience: user -version: "0.261.343" +version: "0.261.345" --- ## What this does @@ -18,6 +18,8 @@ Yamcs. Fixed/Implemented in version: **0.261.333**, recorded in `application/single_app/config.py`. Continuation failure feedback and status recovery were fixed in **0.261.343**. +Fresh agent reconstruction for new same-conversation requests was fixed in +**0.261.345**. ## When a credential card appears @@ -40,6 +42,12 @@ for every field. resumes the blocked work. It does not send a new question or replay completed operations. +The action's result and the assistant's response return in this same +conversation; you do not need to resend the question or start a new chat. +This also applies to a new credential request in a conversation with existing +messages. SimpleChat rebuilds the paused agent with its original tool setup, +while still rejecting changes to its configuration or your access. + Credential inputs are separate from the chat composer. Do not paste passwords, tokens, or API keys into a message. Credentials are not saved as conversation content, sent to the model, or shared with conversation participants. diff --git a/functional_tests/test_action_credentials_chat_reconstruction.py b/functional_tests/test_action_credentials_chat_reconstruction.py new file mode 100644 index 000000000..0872e1045 --- /dev/null +++ b/functional_tests/test_action_credentials_chat_reconstruction.py @@ -0,0 +1,403 @@ +# test_action_credentials_chat_reconstruction.py +"""Fresh-agent save/continue integration through real application owners. + +Version: 0.261.345 +Implemented in: 0.261.345 + +Cold subprocesses keep bootstrap, loaders, authorization, identities, journals, +routes and screened response persistence real. Only external I/O is doubled. +""" + +import json + +import pytest + +from test_orchestration_result_imports import run_probe + + +PROBE = r''' +import asyncio +import base64 +from contextlib import ExitStack, nullcontext +from copy import deepcopy +from functools import wraps +import json +import requests +import sys +from unittest.mock import patch + +sys.path[:0] = sys.argv[1:3] +from test_support.offline_bootstrap import offline_app_imports +from test_action_personal_credentials import CredentialContainer +from semantic_kernel.connectors.ai.open_ai import AzureChatCompletion +from semantic_kernel.contents import ( + ChatMessageContent, FunctionCallContent, FinishReason, + StreamingChatMessageContent, +) + +scenario = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {} + +def check(value, message): + if not value: + raise AssertionError(message) + +with globals().get("credential_ui_context", nullcontext)() as transport, offline_app_imports() as environment: + import app as web + import config + import app_settings_cache + import functions_settings + import functions_workspace_identities as identities + import functions_action_credential_state as state + import semantic_kernel_loader as loader + import agent_delegation_runtime as runtime + import route_backend_chats as chats + import functions_authentication as authentication + import route_backend_action_credentials as credential_routes + import functions_appinsights as telemetry + from action_credential_contracts import ActionCredentialsRequired + from flask import g, session + from semantic_kernel import Kernel + from semantic_kernel.core_plugins import MathPlugin + from yamcs.protobuf.instances.instances_service_pb2 import ListInstancesResponse + + settings = { + **functions_settings.get_settings(), "enable_semantic_kernel": True, + "allow_user_agents": True, "allow_user_plugins": True, + "allow_group_agents": True, "allow_group_plugins": True, "enable_group_workspaces": True, + "enable_user_workspace": True, "per_user_semantic_kernel": True, + "merge_global_semantic_kernel_with_workspace": True, + "enable_key_vault_secret_storage": False, "enable_content_safety": False, + "enable_default_embedding_model_plugin": False, "enable_tabular_processing": False, + "azure_openai_gpt_endpoint": "https://model.example.invalid", + "azure_openai_gpt_key": "local-model-key", "azure_openai_gpt_deployment": "test-model", + "azure_openai_gpt_api_version": "2024-10-21", "max_auto_invoke_attempts": 5, + } + target = { + "id": "telemetry", "name": "telemetry", "display_name": "Telemetry", + "agent_type": "local", "is_enabled": True, "is_global": True, + "instructions": "List the available telemetry instances.", "actions_to_load": ["mission"], + } + scope = scenario.get("scope", "global") + if scope != "global": + target.update(is_global=False, is_group=scope == "group") + target["group_id" if scope == "group" else "user_id"] = "team" if scope == "group" else "owner" + profile = scenario.get("profile", "per-user") + if "fact_memory" in scenario: + settings["enable_fact_memory_plugin"] = scenario["fact_memory"] + else: + settings.pop("enable_fact_memory_plugin", None) + settings["global_selected_agent"] = target + action = { + "id": "mission", "name": "mission", "type": "yamcs", "is_enabled": True, + "endpoint": "https://mission.example.invalid", "metadata": {}, + "auth": {"type": "NoAuth"}, + "additionalFields": {"instance": "sim", "auth_method": "none", "enable_basic_auth": True}, + "credential_requirements": {"version": 1, "slots": { + "proxy": {"source": "personal", "auth_type": "username_password"}, + }}, + } + agent_container = getattr(config, "cosmos_" + scope + "_agents_container") + action_container = getattr(config, "cosmos_" + scope + "_actions_container") + if scope != "global": + action["group_id" if scope == "group" else "user_id"] = "team" if scope == "group" else "owner" + agent_container.upsert_item(target) + action_container.upsert_item(action) + config.cosmos_groups_container.upsert_item({ + "id": "team", "name": "Telemetry Team", "owner": {"id": "owner"}, "users": [{"userId": "owner"}], + }) + user_settings = {"settings": {"enable_agents": True, "selected_agent": target}} + if scope == "group": + user_settings["settings"]["activeGroupOid"] = "team" + state.configure_action_credential_state(CredentialContainer("user_id")) + models, provider_calls, completed_calls, histories, diagnostics, responses = [], [], [], [], [], [ + ChatMessageContent(role="assistant", items=[ + FunctionCallContent(id="completed-sum", name="math-Add", arguments='{"input":2,"amount":3}'), + ]), + ChatMessageContent(role="assistant", items=[ + FunctionCallContent(id="blocked-instances", name="mission-list_instances", arguments="{}"), + ]), + ChatMessageContent(role="assistant", content="sim is RUNNING."), + ] + + async def model_reply(service, history, execution_settings): + histories.append(str(history)) + check(bool(responses), "Unexpected model invocation or replay.") + if len(responses) == 1: + check("RUNNING" in str(history) and len(provider_calls) == 1, + "Model did not receive the actual resumed provider result.") + return [responses.pop(0)] + + async def stream_reply(service, history, execution_settings, function_invoke_attempt=0): + reply = (await model_reply(service, history, execution_settings))[0] + calls = [item for item in reply.items if isinstance(item, FunctionCallContent)] + if calls: + yield [StreamingChatMessageContent(role="assistant", choice_index=0, + finish_reason=FinishReason.TOOL_CALLS, + items=[FunctionCallContent(index=index, id=call.id, name=call.name, arguments=call.arguments) + for index, call in enumerate(calls)])] + else: + yield [StreamingChatMessageContent(role="assistant", choice_index=0, content=reply.content, + finish_reason=FinishReason.STOP)] + + async def invoke_initial(execution, messages): + if scenario.get("streaming"): + return [item async for item in execution.invoke_stream(messages)] + return await execution.invoke(messages) + + create_model = loader.create_model_endpoint_chat_completion_service + def model_service(*args, **kwargs): + service = create_model(*args, **kwargs) + models.append(service) + return service + + def provider_instances(session, request, **kwargs): + header = request.headers.get("Authorization") + expected = "Basic " + base64.b64encode(b"private-user-sentinel:private-password-sentinel").decode() + check(header == expected, "Tool did not use the newly saved credentials.") + check(request.method == "GET" and request.url.endswith("/api/instances"), "Unexpected provider operation.") + provider_calls.append(True) + response = requests.Response() + response.status_code = scenario.get("provider_status", 200) + response._content = ListInstancesResponse(instances=[{"name": "sim", "state": "RUNNING"}]).SerializeToString() + return response + + add = MathPlugin.add + @wraps(add) + def counted_add(*args, **kwargs): + completed_calls.append(True) + return add(*args, **kwargs) + + log_event = credential_routes.log_event + def capture_diagnostic(message, *args, **kwargs): + if message == "[PLUGINS] Credential continuation stopped.": + diagnostics.append(telemetry._build_logger_extra(message, kwargs["extra"])) + return log_event(message, *args, **kwargs) + + with ExitStack() as mocks: + for context in ( + patch.object(asyncio, "run", side_effect=environment.loop.run_until_complete), + patch.object(app_settings_cache, "get_settings_cache", return_value=settings), + patch.object(functions_settings, "get_settings", return_value=settings), + patch.object(functions_settings, "get_user_settings", return_value=user_settings), + patch.object(identities, "get_settings", return_value=settings), + patch.object(loader, "get_user_settings", return_value=user_settings), + patch.object(agent_container, "query_items", side_effect=lambda **kwargs: [deepcopy(target)]), + patch.object(action_container, "query_items", side_effect=lambda **kwargs: [deepcopy(action)]), + patch.object(config.cosmos_groups_container, "query_items", side_effect=lambda **kwargs: [ + deepcopy(row) for row in config.cosmos_groups_container.items.values() + if any(user["userId"] == "owner" for user in row.get("users", [])) + ]), + patch.object(config.cosmos_messages_container, "query_items", side_effect=lambda **kwargs: [ + deepcopy(row) for row in config.cosmos_messages_container.items.values() + if row.get("conversation_id") == "credential-chat" + ]), + patch.object(loader, "create_model_endpoint_chat_completion_service", side_effect=model_service), + patch.object(AzureChatCompletion, "_inner_get_chat_message_contents", model_reply), + patch.object(AzureChatCompletion, "_inner_get_streaming_chat_message_contents", stream_reply), + patch.object(MathPlugin, "add", counted_add), + patch.object(requests.Session, "send", provider_instances), + patch.object(credential_routes, "log_event", side_effect=capture_diagnostic), + patch.object(authentication, "get_user_profile_image", return_value=None), + ): + mocks.enter_context(context) + selected_identity = None + if scenario.get("identity") in {"select", "rotate"}: + identities.create_workspace_identity("personal", "owner", { + "name": "Existing proxy", "provider": "yamcs", "usage_contexts": ["actions"], + "supported_source_types": ["yamcs"], + "credentials": {"auth_type": "username_password", "username": "private-user-sentinel", + "password": "old-password" if scenario["identity"] == "rotate" else "private-password-sentinel"}, + }, "owner", identity_id="existing-proxy") + selected_identity = identities.get_workspace_identity("personal", "owner", "existing-proxy") + with web.app.test_request_context("/"): + session["user"] = {"oid": "owner", "roles": ["User"]} + g.conversation_id = "credential-chat" + g.request_agent_info = deepcopy(target) + chats._create_personal_conversation("owner", "credential-chat") + user_message = { + "id": "original-question", "conversation_id": "credential-chat", "user_id": "owner", + "role": "user", "content": "List the actual telemetry instances.", + "created_at": "2026-10-17T12:00:00Z", + } + prior_messages = [ + ChatMessageContent(role="user", content="Remember this existing conversation."), + ChatMessageContent(role="assistant", content="Ready for the next operation."), + ] if scenario.get("prior_history") else [] + for index, prior in enumerate(prior_messages): + config.cosmos_messages_container.upsert_item({ + **user_message, "id": f"prior-{index}", "role": prior.role.value, "content": prior.content, + }) + config.cosmos_messages_container.upsert_item(user_message) + canonical = runtime.resolve_delegation_agent(target, user_id="owner", settings=settings) + if profile == "global": + original_kernel, agents = loader.load_semantic_kernel(Kernel(), settings) + elif profile == "agent-core": + from agent_execution_context import AgentExecutionFrame, DelegationBudget, agent_execution, capture_execution_identity + frame = AgentExecutionFrame(capture_execution_identity("owner", "credential-chat"), canonical, DelegationBudget()) + with agent_execution(frame): + original_kernel, selected = runtime._build_local_agent(canonical, settings) + agents = {"telemetry": selected} + else: + original_kernel, agents = loader.load_user_semantic_kernel(Kernel(), settings, "owner", None) + check(agents is not None, "Original selected agent was not constructed.") + original = agents["telemetry"] + check(original.construction_profile == profile, "Initial construction profile was lost.") + check("construction_profile" not in original.model_dump(), "Construction metadata escaped serialization.") + check(("document_search" in original_kernel.plugins) == (profile != "per-user"), + "Initial Document Search capabilities changed.") + check(("fact_memory" in original_kernel.plugins) + == settings.get("enable_fact_memory_plugin", profile == "per-user"), + "Initial Fact Memory defaults changed.") + execution = runtime.prepare_agent_execution( + original, canonical, user_id="owner", settings=settings, conversation_id="credential-chat", + ) + try: + asyncio.run(invoke_initial(execution, prior_messages + [ChatMessageContent( + role="user", content="List the actual telemetry instances.", + )])) + except ActionCredentialsRequired as error: + pending = error.credential_request + else: + raise AssertionError("Tool did not pause for personal credentials.") + check(not provider_calls and len(completed_calls) == 1, "Initial tool boundaries were not retained.") + checkpoint = state.read("owner", execution.credential_checkpoint_id) + check("root" in checkpoint["value"], "No retained root tool history.") + client = web.app.test_client() + with client.session_transaction() as browser: + browser["user"] = {"oid": "owner", "roles": ["User"]} + path = "/api/action-credentials/" + pending["id"] + info = client.get(path) + check(info.status_code == 200, "Credential request was not authorized.") + slot = { + "slot": "proxy", "name": "Local proxy", + "credentials": {"auth_type": "username_password", + "username": "private-user-sentinel", "password": "private-password-sentinel"}, + } + if selected_identity: + slot.pop("name") + slot["identity_id"] = selected_identity["id"] + if scenario["identity"] == "select": + slot.pop("credentials") + else: + slot["expected_identity_etag"] = selected_identity["_etag"] + if transport is not None: + continued, ready = transport(client, pending, user_message) + else: + submitted = client.post(path + "/submit", json={ + "expected_etag": pending["etag"], "slots": [slot], + }, headers={"X-CSRFToken": info.json["csrf_token"]}) + check(submitted.status_code == 200, "Credential save failed: " + str(submitted.json)) + ready = submitted.json["credential_request"] + mutation = scenario.get("mutation") + if mutation == "instructions": + target["instructions"] += " Changed after pause." + agent_container.upsert_item(target) + elif mutation == "model": + settings["azure_openai_gpt_deployment"] = "different-model" + elif mutation == "tools": + settings["enable_math_plugin"] = False + elif mutation == "destination": + action["endpoint"] = "https://changed.example.invalid" + action_container.upsert_item(action) + elif mutation == "ownership": + config.cosmos_conversations_container.items["credential-chat"]["user_id"] = "other-user" + elif mutation == "group": + config.cosmos_groups_container.items["team"]["owner"] = {"id": "other-user"} + config.cosmos_groups_container.items["team"]["users"] = [] + persistence = ( + patch.object(chats, "_persist_screened_assistant", side_effect=OSError("private-password-sentinel")) + if scenario.get("persistence_failure") else nullcontext() + ) + with persistence: + continued = client.post(path + "/continue", json={"expected_etag": ready["etag"]}, + headers={"X-CSRFToken": info.json["csrf_token"]}, buffered=True) + frames = continued.get_data(as_text=True) + if scenario.get("persistence_failure") or scenario.get("provider_status") == 401: + expected_status = "failed" if scenario.get("persistence_failure") else "superseded" + check('"done": true' not in frames and len(provider_calls) == 1, "Failure became success or replayed work.") + check(state.read("owner", pending["id"])["status"] == expected_status, "Failure status was not truthful.") + check(len(completed_calls) == 1, "Failure replayed prior work.") + check("private-password-sentinel" not in frames + json.dumps(diagnostics), "Failure leaked credentials.") + if scenario.get("provider_status") == 401: + check('"action_credentials_required"' in frames, "Authentication rejection did not re-challenge.") + else: + check(diagnostics[-1]["sc_stage"] == "credential_response_persistence", "Persistence phase was lost.") + duplicate = client.post(path + "/continue", json={"expected_etag": ready["etag"]}, + headers={"X-CSRFToken": info.json["csrf_token"]}) + check(duplicate.status_code == 409 and len(provider_calls) == 1, "Failed operation was automatically replayed.") + for model in models: + if not model.client.is_closed(): + environment.loop.run_until_complete(model.client.close()) + print("PASS: rejected credentials or response persistence cannot report success") + sys.exit(0) + if scenario.get("mutation"): + check('"done": true' not in frames and not provider_calls, "Changed execution reached the provider.") + check(len(completed_calls) == 1, "Rejected execution replayed prior work.") + if scenario["mutation"] in {"instructions", "model", "tools"}: + check(diagnostics[-1]["sc_failure_code"] == "credential_execution_changed", + "Safe diagnostics lost the guard's stable code.") + check(diagnostics[-1]["sc_stage"] == "credential_tool_resume", + "Safe diagnostics lost the failing stage.") + check(len(diagnostics[-1]["sc_conversation_id_hash"]) == 64, "Correlation was not hashed.") + check("private-password-sentinel" not in frames + json.dumps(diagnostics), "Failure leaked credentials.") + for model in models: + if not model.client.is_closed(): + environment.loop.run_until_complete(model.client.close()) + print("PASS: genuine configuration/access changes remain blocked") + sys.exit(0) + check(continued.status_code == 200 and '"done": true' in frames, + "Same-conversation continuation failed: " + frames) + check(len(provider_calls) == 1, "Blocked provider call did not execute exactly once.") + check(len(completed_calls) == 1, "An earlier completed tool was replayed.") + check(len(models) == 2 and models[0] is not models[1], "Resume did not create a fresh model.") + if prior_messages: + check("Remember this existing conversation." in histories[-1], "Earlier conversation context was lost.") + check(state.read("owner", pending["id"])["status"] == "completed", "Request did not complete.") + answer = json.loads(next(line[6:] for line in frames.splitlines() if '"done": true' in line)) + saved = config.cosmos_messages_container.read_item(answer["message_id"], "credential-chat") + check(saved["conversation_id"] == "credential-chat" and saved["content"] == "sim is RUNNING.", + "Assistant answer was not persisted in the same conversation.") + check(sum(row.get("role") == "user" for row in config.cosmos_messages_container.items.values()) + == 1 + bool(prior_messages), + "Continuation reposted the question.") + check(len(config.cosmos_personal_workspace_identities_container.items) == 1, "Identity was duplicated.") + private_state = json.dumps(list(state.container().items.values())) + check("private-password-sentinel" not in frames + private_state + "".join(histories), + "Credential secret escaped its private identity boundary.") + duplicate = client.post(path + "/continue", json={"expected_etag": ready["etag"]}, + headers={"X-CSRFToken": info.json["csrf_token"]}) + check(duplicate.status_code == 409 and len(provider_calls) == 1, "Duplicate continuation replayed work.") + for model in models: + if not model.client.is_closed(): + environment.loop.run_until_complete(model.client.close()) + check(not environment.network_attempts, "Integration attempted external network I/O.") +print("PASS: fresh agent resumed the blocked tool and persisted the same-conversation answer") +''' + + +@pytest.mark.parametrize("optimized", [False, True]) +def test_new_credential_request_resumes_fresh_agent_in_same_conversation(optimized): + run_probe(PROBE, (), optimized) + + +@pytest.mark.parametrize("scenario", [ + {"scope": "personal", "identity": "select", "fact_memory": False}, + {"scope": "group", "identity": "rotate", "fact_memory": True}, + {"profile": "global", "identity": "select"}, + {"profile": "agent-core"}, + {"streaming": True}, + {"prior_history": True}, +]) +def test_reconstruction_preserves_profiles_and_identity_lifecycle(scenario): + run_probe(PROBE, (json.dumps(scenario),), False) + + +@pytest.mark.parametrize("mutation", ["instructions", "model", "tools", "destination", "ownership", "group"]) +def test_fresh_reconstruction_rejects_real_changes(mutation): + scenario = {"mutation": mutation, "scope": "group" if mutation == "group" else "global"} + run_probe(PROBE, (json.dumps(scenario),), False) + + +@pytest.mark.parametrize("scenario", [{"provider_status": 401}, {"persistence_failure": True}]) +def test_resume_failure_cannot_report_success_or_replay(scenario): + run_probe(PROBE, (json.dumps(scenario),), False) diff --git a/ui_tests/test_v2_action_credentials_backend.py b/ui_tests/test_v2_action_credentials_backend.py new file mode 100644 index 000000000..b5d818e8f --- /dev/null +++ b/ui_tests/test_v2_action_credentials_backend.py @@ -0,0 +1,177 @@ +# test_v2_action_credentials_backend.py +"""Real React Save and continue through Flask and freshly constructed SK agents. + +Version: 0.261.345 +Implemented in: 0.261.345 + +Only Cosmos and external model/provider transport are offline. Browser fetch is +not replaced: credential and message requests execute the actual Flask routes. +The shared connection fixture supports local and Azure Playwright browsers. +""" + +from contextlib import contextmanager +from concurrent.futures import ThreadPoolExecutor +import json +from pathlib import Path +from queue import Queue +import sys +from urllib.parse import urlsplit + +import pytest +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(ROOT / "functional_tests"), str(ROOT / "ui_tests" / "fixtures"), + str(ROOT / "ui_tests" / "fixtures" / "orchestration")] + +# Shared test infrastructure lives outside the UI test module directory. +import harness_build as hb +from playwright_connection import connect_options +from test_action_credentials_chat_reconstruction import PROBE +from test_orchestration_result_imports import run_probe + + +@contextmanager +def credential_browser(options): + """Open browser/driver before blocking application sockets in the backend probe.""" + bundle = hb.ensure_bundle() + errors, requests, replies = [], [], {} + origin = "https://simplechat.test" + with sync_playwright() as playwright, ThreadPoolExecutor(max_workers=1) as backend: + browser = playwright.chromium.connect(**options) if options else playwright.chromium.launch() + context = browser.new_context() + page = context.new_page() + page.on("pageerror", lambda error: errors.append(type(error).__name__)) + assets = { + "/" + hb.HARNESS_HTML_REL: hb.HERE / "harness.html", + "/ui_tests/fixtures/orchestration/harness.bundle.js": bundle, + } + + def assets_only(route): + path = urlsplit(route.request.url).path + if path in assets: + route.fulfill(path=str(assets[path])) + elif path == "/favicon.ico": + route.fulfill(status=204) + else: + errors.append(f"Unexpected browser request: {path}") + route.abort() + + page.route("**/*", assets_only) + page.goto(origin + "/" + hb.HARNESS_HTML_REL) + page.wait_for_function("() => Boolean(window.OrchHarness)") + + def exercise(client, pending, user_message): + def forward(route): + request = route.request + url = urlsplit(request.url) + if not (url.path.startswith("/api/action-credentials/") or url.path in { + "/api/get_messages", "/api/msgraph/pending-actions", + }): + errors.append(f"Unexpected application request: {url.path}") + route.abort() + return + response = backend.submit(client.open, + url.path + (f"?{url.query}" if url.query else ""), + method=request.method, data=request.post_data, + content_type=request.headers.get("content-type"), + headers={"X-CSRFToken": request.headers.get("x-csrftoken", "")}, buffered=True, + ).result(timeout=60) + requests.append(url.path) + if url.path.endswith("/submit"): + replies["ready"] = response.json["credential_request"] + if url.path.endswith("/continue"): + replies["continued"] = response + # The local actor has no Microsoft 365 tenant session. + graph_signed_out = url.path == "/api/msgraph/pending-actions" and response.status_code == 401 + if response.status_code >= 400 and not graph_signed_out: + errors.append(f"Flask rejected {url.path}: {response.status_code}") + route.fulfill(status=response.status_code, content_type=response.content_type, + body=response.get_data()) + + page.route("**/api/**", forward) + page.evaluate("""({pending, userMessage}) => { + const H = window.OrchHarness; + H.reset(); + H.stores.bootstrap.useBootstrapStore.setState({data: {user: {id: 'owner'}}}); + H.stores.chat.useChatStore.setState({ + activeConversationId: pending.conversation_id, messages: [userMessage], + }); + H.mount('mount-a', 'CredentialRequestCards', {conversationId: pending.conversation_id}); + }""", {"pending": pending, "userMessage": user_message}) + card = page.get_by_role("region", name="Personal action credentials") + card.wait_for() + card.get_by_label("Username", exact=True).fill("private-user-sentinel") + card.get_by_label("Password", exact=True).fill("private-password-sentinel") + card.get_by_role("button", name="Save and continue", exact=True).click() + page.wait_for_function("""() => { + const state = window.OrchHarness.stores.chat.useChatStore.getState(); + return !state.streaming && state.messages.some(message => + message.role === 'assistant' && message.content === 'sim is RUNNING.'); + }""") + page.wait_for_function("() => document.querySelectorAll('input[type=password]').length === 0") + snapshot = page.evaluate("""() => ({ + state: window.OrchHarness.stores.chat.useChatStore.getState(), + local: {...localStorage}, session: {...sessionStorage}, + })""") + assert snapshot["state"]["activeConversationId"] == pending["conversation_id"] + assert sum(message["role"] == "user" for message in snapshot["state"]["messages"]) == 1 + assert "private-password-sentinel" not in json.dumps(snapshot) + assert "private-user-sentinel" not in json.dumps(snapshot) + assert requests.count(f"/api/action-credentials/{pending['id']}/submit") == 1 + assert requests.count(f"/api/action-credentials/{pending['id']}/continue") == 1 + assert not errors, errors + return replies["continued"], replies["ready"] + + try: + yield exercise + finally: + context.close() + browser.close() + + +@pytest.mark.ui +def test_save_continue_executes_tool_and_displays_same_conversation_answer(connect_options, monkeypatch): + # Run cold, real app imports without polluting other UI suites' module state. + monkeypatch.setenv("SIMPLECHAT_CREDENTIAL_BROWSER_OPTIONS", json.dumps(connect_options)) + prefix = """ +import json +import os +import sys +sys.path[:0] = sys.argv[1:3] +from pathlib import Path +sys.path.insert(0, str(Path(sys.argv[2]).parent / "ui_tests")) +from test_v2_action_credentials_backend import run_browser_probe +run_browser_probe(json.loads(os.environ.pop("SIMPLECHAT_CREDENTIAL_BROWSER_OPTIONS"))) +""" + run_probe(prefix, (), False) + + +def run_browser_probe(options): + """Keep Playwright's loop and the real application's loop on separate threads.""" + requests, responses = Queue(), Queue() + + @contextmanager + def bridge(): + def transport(*args): + requests.put(args) + result = responses.get(timeout=90) + if isinstance(result, Exception): + raise result + return result + yield transport + + with credential_browser(options) as exercise, ThreadPoolExecutor(max_workers=1) as backend: + future = backend.submit(exec, PROBE, {"credential_ui_context": bridge}) + future.add_done_callback(lambda result: requests.put(None)) + inputs = requests.get(timeout=90) + if inputs is None: + future.result() + raise AssertionError("Backend ended before credential entry.") + try: + result = exercise(*inputs) + except Exception as error: + responses.put(error) + raise + responses.put(result) + future.result(timeout=90) From 3ed7615e4b825eb3951273ea5fd25373759061b2 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Sat, 17 Oct 2026 17:33:38 -0400 Subject: [PATCH 2/2] Make credential browser fixture reuse explicit Reference the shared Playwright fixture through its imported module to retain pytest discovery without an unused import. Record the passing browser checks and bump the patch version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md | 2 +- .../fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md | 7 +++++++ ui_tests/test_v2_action_credentials_backend.py | 6 ++++-- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/application/single_app/config.py b/application/single_app/config.py index 7119a0cfd..30c1c5e92 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.345" +VERSION = "0.261.346" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md b/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md index 7a0a74c36..c624d7ba5 100644 --- a/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md +++ b/docs/explanation/features/PERSONAL_ACTION_CREDENTIALS.md @@ -2,7 +2,7 @@ Fixed/Implemented in version: **0.261.333**. -Current application version: **0.261.345**. +Current application version: **0.261.346**. Related version update: `application/single_app/config.py`. diff --git a/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md b/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md index 45070e42f..9e359047d 100644 --- a/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md +++ b/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md @@ -4,6 +4,13 @@ Fixed/Implemented in version: **0.261.345**. Related version update: `application/single_app/config.py`. +Test-maintenance follow-up in **0.261.346** explicitly exposes the shared +Playwright fixture through its imported module. This preserves module-local +pytest discovery and local/Azure browser selection without an unused import. +The credential browser and connection-contract suites passed **26 tests**. +The application continuation behavior remains the fix implemented in +**0.261.345**. + ## Issue and root cause A personal action identity could save successfully, yet **Save and continue** diff --git a/ui_tests/test_v2_action_credentials_backend.py b/ui_tests/test_v2_action_credentials_backend.py index b5d818e8f..787c8a1eb 100644 --- a/ui_tests/test_v2_action_credentials_backend.py +++ b/ui_tests/test_v2_action_credentials_backend.py @@ -1,7 +1,7 @@ # test_v2_action_credentials_backend.py """Real React Save and continue through Flask and freshly constructed SK agents. -Version: 0.261.345 +Version: 0.261.346 Implemented in: 0.261.345 Only Cosmos and external model/provider transport are offline. Browser fetch is @@ -26,10 +26,12 @@ # Shared test infrastructure lives outside the UI test module directory. import harness_build as hb -from playwright_connection import connect_options +import playwright_connection from test_action_credentials_chat_reconstruction import PROBE from test_orchestration_result_imports import run_probe +connect_options = playwright_connection.connect_options + @contextmanager def credential_browser(options):