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..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.344" +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/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..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.337**. +Current application version: **0.261.346**. 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..9e359047d --- /dev/null +++ b/docs/explanation/fixes/ACTION_CREDENTIAL_CHAT_RECONSTRUCTION_FIX.md @@ -0,0 +1,117 @@ +# Same-conversation action credential reconstruction fix + +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** +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..787c8a1eb --- /dev/null +++ b/ui_tests/test_v2_action_credentials_backend.py @@ -0,0 +1,179 @@ +# test_v2_action_credentials_backend.py +"""Real React Save and continue through Flask and freshly constructed SK agents. + +Version: 0.261.346 +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 +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): + """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)