From dc26690c534e2c09c231572cf7c94876d5a9e60d Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 08:59:57 -0400 Subject: [PATCH 01/12] Seed chat image references in the persistence harness The closed exec namespace in test_v2_prompt_attachment_persistence.py never received prepared_chat_image_references (added to chat_api by #1585), so every chat-path boundary and the two composer-card tests that reuse the harness died with a NameError. - Seed the route's initial value next to the other chat_api locals in a shared _chat_namespace, plus the content-check names and the orchestration/shared-route names the boundaries now read (real pure functions with chat checks off; no production statements copied). - Translate boundary NameErrors into a readable AssertionError naming the missing local. - Add an AST drift check that compares the seed to chat_api's dict literal and fails with the added/dropped keys. - Move the composer-card TypeScript dispatchPlan mock to planner contract 2 with a runnable step, which normalizePlan/isPlanRunnable now require. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_v2_prompt_attachment_persistence.py | 130 ++++++++++++++++-- .../test_v2_prompt_composer_card_logic.ts | 10 +- 2 files changed, 124 insertions(+), 16 deletions(-) diff --git a/functional_tests/test_v2_prompt_attachment_persistence.py b/functional_tests/test_v2_prompt_attachment_persistence.py index 40e8c28a0..11b4ab793 100644 --- a/functional_tests/test_v2_prompt_attachment_persistence.py +++ b/functional_tests/test_v2_prompt_attachment_persistence.py @@ -2,10 +2,11 @@ #!/usr/bin/env python3 """ Functional regression for prompt attachments at actual message persistence boundaries. -Version: 0.261.122 +Version: 0.261.213 Implemented in: 0.261.096 Turn-reuse integration coverage expanded in: 0.261.097 Frozen turn-context persistence integration: 0.261.099 +Harness namespace re-synced with the routes in: 0.261.213 Execute the shipping metadata helper, chat persistence statements, orchestration writer, and shared post/stream routes against in-memory Cosmos containers. Importing the full chat @@ -23,7 +24,7 @@ import uuid from copy import deepcopy from datetime import datetime, timezone -from functools import lru_cache +from functools import lru_cache, partial from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, call @@ -36,6 +37,13 @@ # These pure application modules need the application import path, not app/config startup. import collaboration_models # noqa: E402 +from functions_chat_content_checks import ( # noqa: E402 + CHECK_CONTEXT_KEY, + CHECK_METADATA, + attach_chat_check, + check_chat_content, + strip_private_chat_checks, +) from functions_chat_stream_events import build_user_message_persisted_stream_event # noqa: E402 from functions_prompt_metadata import build_prompt_selection_metadata # noqa: E402 from test_orchestration_conversation_context import load_modules # noqa: E402 @@ -45,6 +53,22 @@ SEND_PATHS = ("chat", "chat-stream", "document-action", "orchestration", "shared-post", "shared-stream") USER = {"user_id": "author-1", "display_name": "Author", "email": "author@example.test"} +BOUNDARY_FILENAME = "" + +# chat_api's initial value for this local, which its new-message branch reads. The closed +# namespace seeds it with the other route locals; a test keeps it equal to the route literal. +ROUTE_IMAGE_REFERENCES_INITIAL = { + "sources": [], + "provenance": [], + "mask": None, + "mask_metadata": None, + "input_fidelity": "", +} + +# Content checks are orthogonal to prompt metadata. With every chat scanner off, the shipping +# check_chat_content returns its "not_required" decision without reaching settings or Azure. +CHAT_CONTENT_CHECKS_OFF = {"enable_content_safety": False, "enable_content_screening": False} +check_chat_content_with_checks_off = partial(check_chat_content, settings=CHAT_CONTENT_CHECKS_OFF) class MemoryContainer: @@ -105,19 +129,36 @@ def _is_message_write(statement): ) +def _raised_by_boundary(error): + frame = error.__traceback__ + while frame.tb_next is not None: + frame = frame.tb_next + return frame.tb_frame.f_code.co_filename == BOUNDARY_FILENAME + + def _execute_boundary(statements, namespace, return_name, events): wrapper = ast.parse("def exercise_boundary():\n pass\n").body[0] wrapper.body = deepcopy(statements) + [ast.Return(value=ast.Name(id=return_name, ctx=ast.Load()))] module = ast.fix_missing_locations(ast.Module(body=[wrapper], type_ignores=[])) - exec(compile(module, "", "exec"), namespace) - result = namespace["exercise_boundary"]() - if not inspect.isgenerator(result): - return result - while True: - try: - events.append(next(result)) - except StopIteration as finished: - return finished.value + exec(compile(module, BOUNDARY_FILENAME, "exec"), namespace) + try: + result = namespace["exercise_boundary"]() + if not inspect.isgenerator(result): + return result + while True: + try: + events.append(next(result)) + except StopIteration as finished: + return finished.value + except NameError as missing: + if isinstance(missing, UnboundLocalError) or not _raised_by_boundary(missing): + raise + # A route started reading a name the closed namespace lacks; say so instead of a bare NameError. + raise AssertionError( + f"The shipping persistence boundary now reads {missing.name!r}, which this harness's " + "closed namespace does not define. Seed it next to the other route locals in the " + "matching *_boundary namespace, using the value the route has at that point." + ) from missing def prompt_cases(): @@ -165,9 +206,8 @@ def prompt_cases(): ] -def _chat_boundary(path, info, content): - storage = MemoryContainer() - events = [] +def _chat_namespace(info, content, storage, events): + """The closed namespace for chat_api, chat_stream_api and document-action branches.""" author = { "userId": USER["user_id"], "displayName": USER["display_name"], "userPrincipalName": USER["email"], "email": USER["email"], @@ -179,6 +219,7 @@ def _chat_boundary(path, info, content): "cosmos_messages_container": storage, "build_prompt_selection_metadata": build_prompt_selection_metadata, "build_user_message_persisted_stream_event": build_user_message_persisted_stream_event, + "attach_chat_check": attach_chat_check, "get_current_user_info": lambda: author, "publish_background_event": events.append, "debug_print": lambda *args, **kwargs: None, @@ -193,12 +234,23 @@ def _chat_boundary(path, info, content): "frontend_gpt_model": "test-deployment", "gpt_endpoint_id": None, "gpt_model_id": None, "gpt_provider": "azure_openai", "gpt_model_icon": None, "gpt_response_length": 1000, "reasoning_effort": None, "search_results": [], + "prepared_chat_image_references": deepcopy(ROUTE_IMAGE_REFERENCES_INITIAL), + "input_check": check_chat_content_with_checks_off( + content, "chat_input", user_id=USER["user_id"], + ), } for flag in ( "image_gen_enabled", "hybrid_search_enabled", "web_search_enabled", "url_access_enabled", "source_review_enabled", "deep_research_enabled", ): namespace[flag] = False + return namespace + + +def _chat_boundary(path, info, content): + storage = MemoryContainer() + events = [] + namespace = _chat_namespace(info, content, storage, events) if path == "document-action": function = _function("route_backend_chats.py", "execute_document_action_chat_request") @@ -250,6 +302,7 @@ def _orchestration_boundary(info, content): storage = MemoryContainer() context = load_modules().context memory = import_app_module("functions_orchestration_memory") + registry = import_app_module("functions_orchestration_registry") authorize = Mock(return_value={"id": "conversation-1", "user_id": USER["user_id"]}) latest_run = Mock(return_value=None) persisted_contexts = [] @@ -275,12 +328,15 @@ def capture_run( "has_request_context": has_request_context, "g": g, "get_latest_turn_run": latest_run, "normalize_history_message": context.normalize_history_message, + "image_reference_provenance_from_seeds": context.image_reference_provenance_from_seeds, "ConversationContextError": context.ConversationContextError, "CosmosResourceNotFoundError": KeyError, "build_prompt_selection_metadata": build_prompt_selection_metadata, "cosmos_messages_container": storage, "datetime": datetime, "timezone": timezone, "uuid": uuid, "logging": logging, "log_event": Mock(), "create_orchestration_run": capture_run, + "CHECK_METADATA": CHECK_METADATA, + "DEPENDENCY_PLAN_CONTRACT_VERSION": registry.DEPENDENCY_PLAN_CONTRACT_VERSION, } _load_functions( "route_backend_orchestration.py", namespace, @@ -374,6 +430,9 @@ def authorize(user_id, document): "CosmosResourceNotFoundError": type("MissingRecord", (Exception,), {}), "build_prompt_selection_metadata": build_prompt_selection_metadata, "build_user_message_persisted_stream_event": build_user_message_persisted_stream_event, + "check_chat_content": check_chat_content_with_checks_off, + "CHECK_CONTEXT_KEY": CHECK_CONTEXT_KEY, + "strip_private_chat_checks": strip_private_chat_checks, "_require_collaboration_feature_enabled": lambda: None, "_get_current_collaboration_user": lambda: USER, "get_collaboration_conversation": lambda identifier: conversations.read_item(identifier, identifier), @@ -537,6 +596,49 @@ def test_only_active_string_variables_are_recorded(): assert metadata["prompt_edited"] is False +def _image_reference_initializers(): + return [ + node for node in ast.walk(_function("route_backend_chats.py", "chat_api")) + if _assigns(node, "prepared_chat_image_references") and isinstance(node.value, ast.Dict) + ] + + +def test_chat_harness_seeds_the_route_initial_image_references(): + initializers = _image_reference_initializers() + namespace = _chat_namespace(None, "Only the user's text.", MemoryContainer(), []) + assert len(initializers) == 1, ( + "Expected chat_api to initialize prepared_chat_image_references with one dict literal, " + f"found {len(initializers)}. Re-check what its new-message branch reads." + ) + initializer = initializers[0].value + try: + route_value = ast.literal_eval(initializer) + except ValueError: + route_value = None + assert isinstance(route_value, dict), ( + "chat_api now initializes prepared_chat_image_references with a non-literal value, " + f"{ast.unparse(initializer)}. Seed the harness with the value the route has at that point." + ) + added = sorted(set(route_value) - set(ROUTE_IMAGE_REFERENCES_INITIAL)) + removed = sorted(set(ROUTE_IMAGE_REFERENCES_INITIAL) - set(route_value)) + assert not added and not removed, ( + "chat_api's prepared_chat_image_references keys drifted from ROUTE_IMAGE_REFERENCES_INITIAL: " + f"the route added {added} and dropped {removed}. Update the seed to the route's literal." + ) + assert route_value == ROUTE_IMAGE_REFERENCES_INITIAL, ( + f"chat_api now initializes prepared_chat_image_references as {route_value!r}; " + "update ROUTE_IMAGE_REFERENCES_INITIAL to the route's literal." + ) + assert "prepared_chat_image_references" in namespace, ( + "The chat harness namespace must seed prepared_chat_image_references: chat_api's " + "new-message branch reads it before the user message is written." + ) + assert namespace["prepared_chat_image_references"] == route_value, ( + "The chat harness must seed chat_api's initial image references, not " + f"{namespace['prepared_chat_image_references']!r}." + ) + + def test_version_includes_the_persistence_fix(): assert_app_version_at_least("0.261.096") diff --git a/functional_tests/test_v2_prompt_composer_card_logic.ts b/functional_tests/test_v2_prompt_composer_card_logic.ts index 955d80335..df0a875fa 100644 --- a/functional_tests/test_v2_prompt_composer_card_logic.ts +++ b/functional_tests/test_v2_prompt_composer_card_logic.ts @@ -1,7 +1,7 @@ // test_v2_prompt_composer_card_logic.ts // // Runtime test for the attached-prompt card's composition and recovery rules. -// Version: 0.261.099 +// Version: 0.261.213 // Implemented in: 0.261.092 // Shared editor implemented in: 0.261.096 // Frozen prompt snapshots implemented in: 0.261.096 @@ -657,13 +657,19 @@ check('dispatchPlan carries the prompt into both the optimistic message and requ assert.equal(optimistic.metadata?.orchestration_turn_id, body.turn_id); assert.deepEqual(optimistic.metadata?.prompt_selection, promptSelectionMetadata(info)); observed = true; + // normalizePlan accepts only planner contract 2, and isPlanRunnable needs an enabled step. return new Response(`data: ${JSON.stringify({ type: 'orchestration_plan', done: true, plan: { plan_id: 'plan-1', run_id: 'run-1', turn_id: body.turn_id, conversation_id: body.conversation_id, user_id: 'author-1', revision: 0, + planner_contract_version: 2, intent: { summary: body.message, complexity: 'simple' }, - steps: [], assumptions: [], status: 'awaiting_approval', + steps: [{ + step_id: 's1', capability_id: 'compose', title: 'Answer', + arguments: {}, role: 'reason', estimated_cost: 'low', + }], + assumptions: [], status: 'awaiting_approval', approval: { mode: 'manual', state: 'pending' }, validation: { valid: true, errors: [], warnings: [], repairs: [] }, }, From 13cf075f383dd459631e0dba726478809a5d91b6 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 09:23:49 -0400 Subject: [PATCH 02/12] Give the auto-open plan fixture its conversation and turn The orchestration controller now adopts a streamed plan only when it carries plan, run and turn ids and names the conversation the turn was started for. The auto-open fixture streamed a plan with neither, so the controller dropped it and four of the five checks failed even as a script. - _plan() now names the conversation the plan was started for, and the mocked plan stream echoes the turn id the client sent, as the server does. - Every page check first asserts the plan was adopted (one plan request for the started conversation, one held plan with the requested turn id), so a dropped plan can no longer pass a "drawer stays shut" check. - The file is converted to plain asserts with a module-scoped harness page fixture, keeping the __main__ runner and its exit code (#1572 guard). Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ui_tests/test_v2_orchestration_auto_open.py | 249 ++++++++++++-------- 1 file changed, 148 insertions(+), 101 deletions(-) diff --git a/ui_tests/test_v2_orchestration_auto_open.py b/ui_tests/test_v2_orchestration_auto_open.py index 3695dcf8c..54454ca12 100644 --- a/ui_tests/test_v2_orchestration_auto_open.py +++ b/ui_tests/test_v2_orchestration_auto_open.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 """ UI test for the V2 chat orchestration auto-open asymmetry. -Version: 0.261.139 +Version: 0.261.213 Implemented in: 0.261.085 +Plan fixture identity re-synced with the controller in: 0.261.213 The drawer opens ITSELF when a plan reaches awaiting-approval only in `manual` mode, and only for the conversation on screen. This is a deliberate design rule, not an accident: manual approval is a @@ -17,14 +18,27 @@ * timed -> the drawer stays shut. * manual, but a different conversation is on screen -> the drawer stays shut. +The controller adopts a streamed plan only when it carries plan, run and turn ids and names the +conversation the turn was started for; it drops any other plan without touching the drawer. So the +mocked stream answers as the server does, naming the started conversation and echoing the turn id +the client minted, and every case first asserts that the plan was adopted. A dropped plan can never +pass a "drawer stays shut" check. + No Azure credentials and no server are needed: the plan and run streams are mocked in the browser and the controller and stores are the code that ships. The browser checks are skipped (reported, not failed) when application/v2_ui/node_modules is absent. + +Run: python ui_tests/test_v2_orchestration_auto_open.py + or python -m pytest ui_tests/test_v2_orchestration_auto_open.py -q """ import sys +import traceback +from contextlib import ExitStack from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).resolve().parent / "fixtures" / "orchestration")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "functional_tests")) @@ -32,13 +46,20 @@ from test_support.versioning import assert_app_version_at_least # noqa: E402 +pytestmark = pytest.mark.ui + IMPLEMENTED_IN = "0.261.085" +# Every plan is started for CONVERSATION; OTHER_CONVERSATION is a different thread put on screen. +CONVERSATION = "c1" +OTHER_CONVERSATION = "other" -_PAGE = None +def _plan(mode, conversation_id): + """A runnable plan for ``conversation_id`` in the given approval mode: a search, then the answer. -def _plan(mode): - """A runnable plan carrying the given approval mode, with a search step then the answer.""" + It carries no turn id on purpose. The client mints the turn id when the turn starts and the + server echoes the one it was sent, so the mocked plan stream in _DRIVE_PLAN stamps it on. + """ if mode == "auto": approval = {"mode": "auto", "timeout_seconds": 0, "state": "approved"} status = "approved" @@ -48,6 +69,7 @@ def _plan(mode): return { "plan_id": f"plan-{mode}", "run_id": f"run-{mode}", + "conversation_id": conversation_id, "planner_contract_version": 2, "intent": {"summary": f"A {mode} plan", "complexity": "simple"}, "steps": [ @@ -74,6 +96,7 @@ def _plan(mode): # Seed visibility, mock the plan (and run) streams, and drive a fresh plan through the controller. +# The result reports what the controller requested and adopted, so a dropped plan is visible. _DRIVE_PLAN = r""" async (spec) => { const H = window.OrchHarness; @@ -81,14 +104,19 @@ def _plan(mode): const { conv, visibleConv, mode, plan } = spec; const orch = H.stores.orchestration.useOrchestrationStore; const chat = H.stores.chat.useChatStore; - chat.setState({ activeConversationId: conv, drawerMode: null }); + chat.setState({ activeConversationId: conv, drawerMode: null, streamError: null }); orch.getState().setVisibleConversation(visibleConv); const encoder = new TextEncoder(); - window.fetch = (url) => { + const planRequests = []; + window.fetch = (url, options = {}) => { const requestUrl = String(url); if (requestUrl.includes('/api/v2/orchestration/plan')) { - const frame = 'data: ' + JSON.stringify({ type: 'orchestration_plan', plan }) + '\n\n'; + const request = typeof options.body === 'string' ? JSON.parse(options.body) : {}; + planRequests.push({ conversation_id: request.conversation_id, turn_id: request.turn_id }); + // As the server does, echo the turn id the client minted for this turn on the plan. + const streamed = { ...plan, turn_id: request.turn_id }; + const frame = 'data: ' + JSON.stringify({ type: 'orchestration_plan', plan: streamed }) + '\n\n'; const body = new ReadableStream({ start(controller) { controller.enqueue(encoder.encode(frame)); @@ -126,117 +154,131 @@ def _plan(mode): }); await new Promise((resolve) => setTimeout(resolve, 40)); + const plans = Object.values(orch.getState().plans); return { drawerMode: chat.getState().drawerMode, - planCount: Object.keys(orch.getState().plans).length, + streamError: chat.getState().streamError, + planCount: plans.length, + plans: plans.map((held) => ({ + plan_id: held.plan_id, + conversation_id: held.conversation_id, + turn_id: held.turn_id, + })), + planRequests, }; } """ +def _drive_plan(page, mode, visible_conversation): + """Start a fresh ``mode`` plan for CONVERSATION while ``visible_conversation`` is on screen.""" + return page.evaluate( + _DRIVE_PLAN, + { + "conv": CONVERSATION, + "visibleConv": visible_conversation, + "mode": mode, + "plan": _plan(mode, CONVERSATION), + }, + ) + + +def _assert_plan_adopted(result, mode): + """Assert the controller adopted the one streamed plan, for the conversation and turn it began. + + The controller drops a plan that lacks an id or names another conversation, and a dropped plan + leaves the drawer shut too. Checking adoption first keeps each drawer assertion meaningful. + """ + requests = result["planRequests"] + assert len(requests) == 1, f"the controller must request exactly one plan, saw {result!r}" + turn_id = requests[0]["turn_id"] + assert requests[0]["conversation_id"] == CONVERSATION and turn_id, ( + f"the plan request must name {CONVERSATION!r} and carry the client's turn id, saw {result!r}" + ) + assert result["planCount"] == 1, ( + f"the controller must adopt the streamed plan, but it holds {result['planCount']} plan(s) " + f"and reported {result['streamError']!r}" + ) + expected = {"plan_id": f"plan-{mode}", "conversation_id": CONVERSATION, "turn_id": turn_id} + assert result["plans"] == [expected], ( + f"the adopted plan must belong to {CONVERSATION!r} and the requested turn, saw {result!r}" + ) + + +def _report_page_errors(errors): + """Print uncaught page errors for diagnosis; as before, they are reported, not failed on.""" + if errors: + print("\nUncaught page errors observed during the run:") + for message in errors: + print(f" !! {message}") + + +@pytest.fixture(scope="module") +def orchestration_page(): + """One harness page shared by the module; skipped, not failed, without the v2_ui toolchain.""" + errors = [] + with ExitStack() as stack: + try: + page = stack.enter_context(hb.harness_page(collect_errors=errors)) + except hb.HarnessUnavailable as exc: + pytest.skip(f"skipped the browser-driven checks: {exc}") + yield page + _report_page_errors(errors) + + def test_version_is_at_least_the_implementing_release(): """The auto-open rule shipped in IMPLEMENTED_IN, so the app must be at least that version.""" print("Testing the app version is at least the implementing release...") - try: - assert_app_version_at_least(IMPLEMENTED_IN) - print(f" ok config.py VERSION is at least {IMPLEMENTED_IN}") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - return False + assert_app_version_at_least(IMPLEMENTED_IN) + print(f" ok config.py VERSION is at least {IMPLEMENTED_IN}") -def test_manual_plan_opens_the_drawer(): +def test_manual_plan_opens_the_drawer(orchestration_page): """A manual plan for the visible conversation opens the drawer to plan mode by itself.""" print("Testing a manual plan auto-opens the drawer...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "c1", "mode": "manual", "plan": _plan("manual")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] == "plan", ( - f"manual must auto-open the drawer, saw {result['drawerMode']!r}" - ) - print(" ok the manual plan opened the drawer to plan mode") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback - - traceback.print_exc() - return False + result = _drive_plan(orchestration_page, "manual", visible_conversation=CONVERSATION) + _assert_plan_adopted(result, "manual") + assert result["drawerMode"] == "plan", ( + f"manual must auto-open the drawer, saw {result['drawerMode']!r}" + ) + print(" ok the manual plan opened the drawer to plan mode") -def test_auto_plan_leaves_the_drawer_shut(): +def test_auto_plan_leaves_the_drawer_shut(orchestration_page): """An auto plan is pre-approved and runs, but must not throw the drawer open.""" print("Testing an auto plan leaves the drawer shut...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "c1", "mode": "auto", "plan": _plan("auto")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] is None, ( - f"auto must leave the drawer shut, saw {result['drawerMode']!r}" - ) - print(" ok the auto plan ran without opening the drawer") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback - - traceback.print_exc() - return False + result = _drive_plan(orchestration_page, "auto", visible_conversation=CONVERSATION) + _assert_plan_adopted(result, "auto") + assert result["drawerMode"] is None, ( + f"auto must leave the drawer shut, saw {result['drawerMode']!r}" + ) + print(" ok the auto plan ran without opening the drawer") -def test_timed_plan_leaves_the_drawer_shut(): +def test_timed_plan_leaves_the_drawer_shut(orchestration_page): """A timed plan carries its countdown on the inline card, so the drawer stays shut.""" print("Testing a timed plan leaves the drawer shut...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "c1", "mode": "timed", "plan": _plan("timed")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] is None, ( - f"timed must leave the drawer shut, saw {result['drawerMode']!r}" - ) - print(" ok the timed plan left the drawer shut") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback + result = _drive_plan(orchestration_page, "timed", visible_conversation=CONVERSATION) + _assert_plan_adopted(result, "timed") + assert result["drawerMode"] is None, ( + f"timed must leave the drawer shut, saw {result['drawerMode']!r}" + ) + print(" ok the timed plan left the drawer shut") - traceback.print_exc() - return False +def test_manual_plan_for_another_conversation_stays_shut(orchestration_page): + """Even a manual plan must not yank a panel open over a different conversation on screen. -def test_manual_plan_for_another_conversation_stays_shut(): - """Even a manual plan must not yank a panel open over a different conversation on screen.""" + The plan is still adopted for the conversation it was started for; only the drawer stays shut. + """ print("Testing a manual plan for an off-screen conversation stays shut...") - page = _PAGE - try: - result = page.evaluate( - _DRIVE_PLAN, - {"conv": "c1", "visibleConv": "other", "mode": "manual", "plan": _plan("manual")}, - ) - assert result["planCount"] >= 1, "the plan stream must have produced a plan" - assert result["drawerMode"] is None, ( - f"a plan for an off-screen conversation must not open the drawer, " - f"saw {result['drawerMode']!r}" - ) - print(" ok the off-screen manual plan did not open the drawer") - return True - except Exception as exc: # noqa: BLE001 - print(f"Test failed: {exc}") - import traceback - - traceback.print_exc() - return False + result = _drive_plan(orchestration_page, "manual", visible_conversation=OTHER_CONVERSATION) + _assert_plan_adopted(result, "manual") + assert result["drawerMode"] is None, ( + f"a plan for an off-screen conversation must not open the drawer, " + f"saw {result['drawerMode']!r}" + ) + print(" ok the off-screen manual plan did not open the drawer") PAGE_TESTS = [ @@ -247,27 +289,32 @@ def test_manual_plan_for_another_conversation_stays_shut(): ] +def _run(test, *args): + """Run one check for the script runner, reporting a failure rather than stopping the run.""" + try: + test(*args) + except Exception as exc: # noqa: BLE001 - every failure is reported, then the run carries on + print(f"Test failed: {exc}") + traceback.print_exc() + return False + return True + + def main(): - results = [test_version_is_at_least_the_implementing_release()] + results = [_run(test_version_is_at_least_the_implementing_release)] errors = [] try: with hb.harness_page(collect_errors=errors) as page: - global _PAGE - _PAGE = page for test in PAGE_TESTS: print(f"\nRunning {test.__name__}...") page.evaluate("() => window.OrchHarness.reset()") - results.append(test()) + results.append(_run(test, page)) except hb.HarnessUnavailable as exc: print(f"\n -- skipped the browser-driven checks: {exc}") results.extend([True] * len(PAGE_TESTS)) - if errors: - print("\nUncaught page errors observed during the run:") - for message in errors: - print(f" !! {message}") - + _report_page_errors(errors) print(f"\nResults: {sum(results)}/{len(results)} tests passed") return all(results) From d5906cdf6fefc860ae19c69669de73221fa8473d Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 09:29:02 -0400 Subject: [PATCH 03/12] Give the plugin repair harness the action type helpers it now calls 73ff7d699 taught repair_plugin to resolve the action type with resolve_action_type and to pick the plugin class with the module helper _find_plugin_class. The isolated exec namespace provided neither, so the route's own except block swallowed a NameError, returned 500 without writing, and all 18 repair cases failed with "assert 0 == 1". - Import the dependency-free functions_action_manifest.resolve_action_type as shipped and AST-extract the real _find_plugin_class, the same way the route itself is loaded. - Capture the route's log_event so a missing name now fails with the logged error instead of an empty call count. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_app_settings_auxiliary_writers.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/functional_tests/test_app_settings_auxiliary_writers.py b/functional_tests/test_app_settings_auxiliary_writers.py index 97ef258a9..063b31169 100644 --- a/functional_tests/test_app_settings_auxiliary_writers.py +++ b/functional_tests/test_app_settings_auxiliary_writers.py @@ -1,12 +1,14 @@ # test_app_settings_auxiliary_writers.py """ Functional tests for auxiliary app-settings writers. -Version: 0.261.025 +Version: 0.261.213 Implemented in: 0.261.025 Execute isolated production functions through AST extraction, without importing application configuration or contacting Redis, Cosmos DB, or other cloud services. -Verify field-only updates, optimistic concurrency, and rejected-write responses. +The dependency-free action manifest contract (functions_action_manifest) is +imported as shipped. Verify field-only updates, optimistic concurrency, and +rejected-write responses. """ import ast @@ -22,6 +24,10 @@ APP_DIR = Path(__file__).resolve().parents[1] / "application" / "single_app" +sys.path.insert(0, str(APP_DIR)) + +from functions_action_manifest import resolve_action_type # noqa: E402 + WRITER_FILES = ( "background_tasks.py", "functions_control_center.py", @@ -432,14 +438,23 @@ def save_global_action(manifest): raise RuntimeError("container unavailable") return manifest if container_result == "saved" else None + logged = [] route = load_function( "plugin_validation_endpoint.py", "repair_plugin", store, discover_plugins=lambda: {"Example": object}, PluginHealthChecker=health_checker, PluginErrorRecovery=recovery, save_global_action=save_global_action, + resolve_action_type=resolve_action_type, + _find_plugin_class=load_function( + "plugin_validation_endpoint.py", "_find_plugin_class", store, + resolve_action_type=resolve_action_type, + ), + log_event=lambda message, *args, **kwargs: logged.append(message), ) result, status = response_parts(route("repair-me")) + # The route reports any failure, including a name this harness lacks, through log_event. + assert store.calls, f"repair_plugin must reach its settings write, but it logged {logged!r}" assert_delta(store, {"semantic_kernel_plugins"}, etag='"original"') assert status == (200 if available and not conflict else 500) assert result["success"] is (available and not conflict) From a598ef3fb6599995c21321db8d34b431d462d22e Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 09:31:40 -0400 Subject: [PATCH 04/12] Read the diagram edit prompt from the request the assist builds 26e8cae14 added chart editing and gave each block kind its own system prompt, renaming ASSIST_SYSTEM_PROMPT to DIAGRAM_ASSIST_SYSTEM_PROMPT. The positioning check still read the old constant and failed with an AttributeError, although the shipped diagram prompt still carries every phrase it asserts. Read the system message build_assist_messages sends for a diagram edit instead, so the check follows the prompt that is actually sent rather than a constant name. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- functional_tests/test_block_revision_assist.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/functional_tests/test_block_revision_assist.py b/functional_tests/test_block_revision_assist.py index dce3abaf6..f2670a0f1 100644 --- a/functional_tests/test_block_revision_assist.py +++ b/functional_tests/test_block_revision_assist.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """ Functional test for the scoped diagram edit request. -Version: 0.261.043 +Version: 0.261.213 Implemented in: 0.261.043 This test ensures that asking the AI to change a diagram sends only that diagram's own @@ -178,7 +178,11 @@ def test_the_prompt_is_honest_about_positioning(): """The model must not be encouraged to fake node placement Mermaid cannot express.""" print("Testing positioning guidance in the prompt...") try: - prompt = assist.ASSIST_SYSTEM_PROMPT + # Read the system prompt a diagram edit actually sends rather than a module constant: + # adding chart editing renamed the constant when it gave each block kind its own prompt. + messages = build_assist_messages(CURRENT_SOURCE, 'put B to the left of A') + assert messages[0]['role'] == 'system' + prompt = messages[0]['content'] assert 'no syntax for placing a node at a coordinate' in prompt assert 'flow direction' in prompt # And the prompt injection instruction is present, since the source is untrusted. From 82e1cc75172697f1b6dc99a1d4b1fb8463d3935b Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 09:35:06 -0400 Subject: [PATCH 05/12] Seed the image-reference provenance helper in the turn recovery test test_run_record_keeps_the_turn_and_question_ids execs the route's real _validate_turn_memory_context and _persist_planned_turn in a hand-built namespace. Since the image-reference work (#1585), _persist_planned_turn calls image_reference_provenance_from_seeds() for the saved user message, and the namespace never received it, so the test died with a NameError (script 5/6; pytest 1 failed under the #1572 guard). Inject the real helper from functions_orchestration_context through the existing import_app_module stub seam. It is a pure function, and a turn with no seeds yields [], so the asserted identifiers are unchanged. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_orchestration_turn_recovery_identifiers.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/functional_tests/test_orchestration_turn_recovery_identifiers.py b/functional_tests/test_orchestration_turn_recovery_identifiers.py index a55e98415..76e1da28f 100644 --- a/functional_tests/test_orchestration_turn_recovery_identifiers.py +++ b/functional_tests/test_orchestration_turn_recovery_identifiers.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional test for the identifiers that let an orchestration turn be found again. -Version: 0.261.131 +Version: 0.261.213 Implemented in: 0.261.099 Chat content-check metadata binding included in: 0.261.131 @@ -151,6 +151,7 @@ def create_run(plan, user_id, **kwargs): memory = import_app_module("functions_orchestration_memory") checks = import_app_module("functions_chat_content_checks") + context = import_app_module("functions_orchestration_context") authorize = Mock(return_value={"id": "conversation-1", "user_id": "user-1"}) latest_run = Mock(return_value=None) namespace = { @@ -159,6 +160,7 @@ def create_run(plan, user_id, **kwargs): '_authorize_context_conversation': authorize, 'get_latest_turn_run': latest_run, 'CHECK_METADATA': checks.CHECK_METADATA, + 'image_reference_provenance_from_seeds': context.image_reference_provenance_from_seeds, 'g': g, 'has_request_context': has_request_context, } for name in ('_validate_turn_memory_context', '_persist_planned_turn'): From a55863564f91a04553b6a814f19a699c4fa7b147 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 09:50:38 -0400 Subject: [PATCH 06/12] test: confirm picked documents in plan revision harness Planning now confirms every picked document against the strict current-authority manifest (0401fdb82). The shared conversation-route harness stubs candidate discovery to ([], []), so both picked documents in test_document_removal_is_not_lost_when_editor_opens were reported as unavailable and planning refused with "A selected document could not be opened". Scope a helper to that one test: restore the production explicit-selection branch of resolve_candidate_documents (no service call), and answer the strict manifest seam from the test's existing authority stub, which now carries the source_kind real manifests do. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...test_orchestration_plan_revision_routes.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/functional_tests/test_orchestration_plan_revision_routes.py b/functional_tests/test_orchestration_plan_revision_routes.py index ca4774915..30177316f 100644 --- a/functional_tests/test_orchestration_plan_revision_routes.py +++ b/functional_tests/test_orchestration_plan_revision_routes.py @@ -1,7 +1,7 @@ # test_orchestration_plan_revision_routes.py """ Functional tests for conversational, pre-execution plan revisions. -Version: 0.261.200 +Version: 0.261.213 Implemented in: 0.261.102 Authorized model routing through revisions and clarification: 0.261.103 Unroutable Auto revisions report model_unavailable: 0.261.134 @@ -12,6 +12,7 @@ Only model, search, source-access, and Cosmos service boundaries are replaced. """ +import importlib import json import unittest import uuid @@ -108,12 +109,13 @@ def manifest(ids, user_id, **kwargs): return [ { 'document_id': document_id, 'file_name': f'{document_id}.pdf', - 'scope': 'personal', + 'scope': 'personal', 'source_kind': 'narrative', 'authorization_status': 'authorized' if document_id in self.allowed_documents else 'denied', } for document_id in ids ] + self.source_manifest = manifest self.model.chat.completions.create = completion # The config-stub loader restores sys.modules; patch the route-bound module. patcher = patch.dict(self.route.build_plan_edit_outcome.__globals__, { @@ -375,8 +377,26 @@ def test_review_narrowing_reaches_the_planner(self): self.assertFalse(next(step for step in supplied['steps'] if step['step_id'] == 'search_1')['enabled']) self.assertEqual(updated['edits'], {'disabled_step_ids': [], 'removed_document_ids': {}}) + def use_picked_documents(self): + """Plan with picked documents under the same authority the plan editor uses. + + Planning confirms every picked document against the strict current-authority + manifest before the planner sees it, and the base harness stubs candidate discovery + out. Restore the production explicit-selection branch, which makes no service call, + and answer the strict check from ``allowed_documents``. + """ + context = self.route.enrich_planner_candidates.__globals__ + source_access = importlib.import_module('functions_orchestration_source_access') + for patcher in ( + patch.object(self.route, 'resolve_candidate_documents', context['resolve_candidate_documents']), + patch.object(source_access, 'resolve_orchestration_source_manifest', self.source_manifest), + ): + patcher.start() + self.addCleanup(patcher.stop) + def test_document_removal_is_not_lost_when_editor_opens(self): self.allowed_documents = {'docA', 'docB'} + self.use_picked_documents() self.model.plan_override = revised_plan(searches=1) self.model.plan_override['steps'][0]['arguments']['document_ids'] = ['docA', 'docB'] plan = self.planned(selected_document_ids=['docA', 'docB']) From 75c9cfe32b7b58171911fca0aabd1dbcab561d34 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 10:09:50 -0400 Subject: [PATCH 07/12] test: follow workflow search citation wiring and restore picker stubs 778c10fa0 moved the workflow runner's inline hybrid_citations update into _attach_workflow_search_context, so the source contract now checks the helper's body (scoped with ast) and that the runner routes Search results through it. The picker test also left stub functions_search/functions_document_analysis modules, and a functions_document_actions bound to them, in sys.modules for the rest of the process; a later `from functions_search import build_tags_filter` failed with ImportError. The stubs are now installed by a context manager that restores those three entries afterwards, and a new test fails if any module installed over the stubs outlives its test. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...workflow_document_picker_recent_targets.py | 71 +++++++++++++++++-- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/functional_tests/test_workflow_document_picker_recent_targets.py b/functional_tests/test_workflow_document_picker_recent_targets.py index 69a5d40b6..3ba8ba9c0 100644 --- a/functional_tests/test_workflow_document_picker_recent_targets.py +++ b/functional_tests/test_workflow_document_picker_recent_targets.py @@ -2,7 +2,7 @@ # test_workflow_document_picker_recent_targets.py """ Functional test for workflow document picker and recent workflow document targets. -Version: 0.250.225 +Version: 0.261.213 Implemented in: 0.241.188 Enhanced in: 0.250.225 @@ -11,10 +11,12 @@ document workflows can be saved and resolved at run time. """ +import ast import importlib import os import sys import types +from contextlib import contextmanager from pathlib import Path from test_support.versioning import assert_app_version_at_least @@ -23,8 +25,12 @@ APP_ROOT = REPO_ROOT / 'application' / 'single_app' sys.path.insert(0, str(APP_ROOT)) +DOCUMENT_ACTIONS_MODULE = 'functions_document_actions' +MODULES_INSTALLED_OVER_STUBS = [] -if 'functions_document_analysis' not in sys.modules: + +def build_document_action_stubs() -> dict: + """Build stand-ins for the service-backed modules functions_document_actions imports.""" document_analysis_stub = types.ModuleType('functions_document_analysis') document_analysis_stub.CHAT_DOCUMENT_ANALYSIS_MAX_DOCUMENTS = 3 document_analysis_stub.WORKFLOW_DOCUMENT_ANALYSIS_MAX_DOCUMENTS = 10 @@ -51,9 +57,7 @@ def normalize_document_analysis_targets(document_ids=None, max_documents=None, * } document_analysis_stub.normalize_document_analysis_targets = normalize_document_analysis_targets - sys.modules['functions_document_analysis'] = document_analysis_stub -if 'functions_search' not in sys.modules: search_stub = types.ModuleType('functions_search') def normalize_search_id_list(values=None): @@ -88,7 +92,36 @@ def normalize_search_top_n(top_n, default_top_n=12, max_top_n=50): search_stub.normalize_search_id_list = normalize_search_id_list search_stub.normalize_search_scope = normalize_search_scope search_stub.normalize_search_top_n = normalize_search_top_n - sys.modules['functions_search'] = search_stub + return { + 'functions_document_analysis': document_analysis_stub, + 'functions_search': search_stub, + } + + +@contextmanager +def document_actions_over_stubs(): + """Import functions_document_actions over stubs, then restore those sys.modules entries. + + Only the stubs and the module bound to them are restored. Third-party modules the + import loads stay loaded, because unloading compiled extensions mid-process is unsafe. + """ + stubs = build_document_action_stubs() + names = (*stubs, DOCUMENT_ACTIONS_MODULE) + previous = {name: sys.modules[name] for name in names if name in sys.modules} + try: + for name, stub in stubs.items(): + sys.modules.setdefault(name, stub) + document_actions = importlib.import_module(DOCUMENT_ACTIONS_MODULE) + MODULES_INSTALLED_OVER_STUBS.extend( + (name, sys.modules[name]) for name in names if name not in previous + ) + yield document_actions + finally: + for name in names: + if name in previous: + sys.modules[name] = previous[name] + else: + sys.modules.pop(name, None) WORKSPACE_TEMPLATE = APP_ROOT / 'templates' / 'workspace.html' @@ -104,6 +137,13 @@ def read_text(path: Path) -> str: return path.read_text(encoding='utf-8') +def function_source(source: str, function_name: str) -> str: + for node in ast.walk(ast.parse(source)): + if isinstance(node, ast.FunctionDef) and node.name == function_name: + return ast.get_source_segment(source, node) + raise AssertionError(f'{function_name} was not found in the workflow runner.') + + def test_workflow_modals_use_picker_contracts() -> None: print('Testing workflow modal picker contracts...') workspace_template = read_text(WORKSPACE_TEMPLATE) @@ -186,8 +226,11 @@ def test_workflow_picker_javascript_contracts() -> None: def test_recent_target_backend_normalization() -> None: print('Testing recent workflow target backend normalization...') - document_actions = importlib.import_module('functions_document_actions') + with document_actions_over_stubs() as document_actions: + assert_recent_target_normalization(document_actions) + +def assert_recent_target_normalization(document_actions) -> None: search_action = document_actions.normalize_document_action_config({ 'type': 'search', 'document_ids': [], @@ -283,6 +326,16 @@ def test_recent_target_backend_normalization() -> None: assert resolved_comparison_action['recent_targets_resolved'] is True +def test_document_action_stubs_do_not_outlive_their_test() -> None: + print('Testing that document action stubs are removed after use...') + with document_actions_over_stubs(): + pass + left_behind = sorted({ + name for name, module in MODULES_INSTALLED_OVER_STUBS if sys.modules.get(name) is module + }) + assert not left_behind, f'Modules installed over stubs are still in sys.modules: {left_behind}' + + def test_recent_target_runner_hooks() -> None: print('Testing recent target runner hooks...') workflow_runner = read_text(WORKFLOW_RUNNER_PY) @@ -311,7 +364,10 @@ def test_recent_target_runner_hooks() -> None: assert "resolved_action['left_document_id'] = document_ids[0]" in workflow_runner assert "resolved_action['right_document_ids'] = document_ids[1:]" in workflow_runner assert 'search_documents(' in workflow_runner - assert "'hybrid_citations': workflow_search_context.get('citations') or []" in workflow_runner + attach_search_context = function_source(workflow_runner, '_attach_workflow_search_context') + assert "list(search_context.get('citations') or [])" in attach_search_context + assert "'hybrid_citations': hybrid_citations," in attach_search_context + assert 'execution_result = _attach_workflow_search_context(execution_result, workflow_search_context)' in workflow_runner assert 'workflow = _apply_runtime_document_action_config(workflow, action_config)' in workflow_runner @@ -320,6 +376,7 @@ def run_tests() -> bool: test_workflow_modals_use_picker_contracts, test_workflow_picker_javascript_contracts, test_recent_target_backend_normalization, + test_document_action_stubs_do_not_outlive_their_test, test_recent_target_runner_hooks, ] results = [] From dffb8aadc543ea39cc19eea96320833157c81c66 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 10:15:41 -0400 Subject: [PATCH 08/12] test: read workflow gating chains through iter_field_dependencies group_workflow_allowed_group_ids declares a two-link depends_on chain (allow_group_workflows, then require_group_assignment_for_group_workflows), so test_sub_settings_are_gated_by_their_capability crashed with "'list' object has no attribute 'get'". Its docstring already recorded a chain-aware fix, but that fix never landed in the code. The check now reads every link through the application's own iter_field_dependencies. The expected gate must be one of the links, with equals True, and any other link must be one the gate itself requires. For a single condition that is the original check. The ungated-limit message path had the same single-dict assumption and is fixed too. EXPECTED_DEPENDENCIES is unchanged; Phase 6a (#1592) edits it. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_v2_admin_workflow_parity.py | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/functional_tests/test_v2_admin_workflow_parity.py b/functional_tests/test_v2_admin_workflow_parity.py index 235fabb2e..4b3ca17cf 100644 --- a/functional_tests/test_v2_admin_workflow_parity.py +++ b/functional_tests/test_v2_admin_workflow_parity.py @@ -2,7 +2,7 @@ # test_v2_admin_workflow_parity.py """ Functional test pinning V1/V2 parity for the Admin Settings Workflow group. -Version: 0.261.208 +Version: 0.261.213 Implemented in: 0.261.059 The Workflow group rendered completely empty in the V2 React admin surface. The @@ -248,10 +248,32 @@ def test_sub_settings_are_gated_by_their_capability(): Worth remembering as a shape: fixing a duplicate declaration can expose a bug in code that only ever saw the other copy. + + The chain-aware reading had not landed with that note, so the crash stayed live. + Every link is now read through ``iter_field_dependencies``, as the renderer reads + it. A list means every link must hold, so link order carries no meaning: the + expected gate has to be one of the links, and any other link has to be one the + gate itself requires, because a link the gate does not need hides the field + while its capability is live. """ print("\nTesting the workflow gating chain...") declared = {field["key"]: field for field in workflow_fields() if field.get("key")} + fields_by_key = { + field["key"]: field + for _section_id, field in fields_module.iter_fields() + if field.get("key") + } + + def inherited_conditions(gate_key, seen=None): + """Every (key, equals) a gate is itself gated on, followed up its own gates.""" + seen = set() if seen is None else seen + for condition in fields_module.iter_field_dependencies(fields_by_key.get(gate_key, {})): + entry = (condition.get("key"), condition.get("equals", True)) + if entry not in seen: + seen.add(entry) + inherited_conditions(entry[0], seen) + return seen problems = [] for key, expected_gate in EXPECTED_DEPENDENCIES.items(): @@ -259,13 +281,28 @@ def test_sub_settings_are_gated_by_their_capability(): if field is None: problems.append(f"{key}: not declared") continue - depends_on = field.get("depends_on") or {} - if depends_on.get("key") != expected_gate: + conditions = list(fields_module.iter_field_dependencies(field)) + gate_links = [c for c in conditions if c.get("key") == expected_gate] + if not gate_links: + problems.append( + f"{key}: gated on {[c.get('key') for c in conditions]!r}, " + f"expected {expected_gate!r}" + ) + for condition in gate_links: + if condition.get("equals") is not True: + problems.append(f"{key}: gate expects {condition.get('equals')!r}, not True") + allowed = {(expected_gate, True)} | inherited_conditions(expected_gate) + unrelated = [ + condition.get("key") + for condition in conditions + if condition.get("key") != expected_gate + and (condition.get("key"), condition.get("equals", True)) not in allowed + ] + if unrelated: problems.append( - f"{key}: gated on {depends_on.get('key')!r}, expected {expected_gate!r}" + f"{key}: also gated on {unrelated!r}, which {expected_gate!r} does not " + "itself require, so the field hides while its capability is live" ) - if depends_on.get("equals") is not True: - problems.append(f"{key}: gate expects {depends_on.get('equals')!r}, not True") for key in UNGATED_KEYS: field = declared.get(key) @@ -273,8 +310,9 @@ def test_sub_settings_are_gated_by_their_capability(): problems.append(f"{key}: not declared") continue if field.get("depends_on"): + gates = [c.get("key") for c in fields_module.iter_field_dependencies(field)] problems.append( - f"{key}: gated on {field['depends_on'].get('key')!r}. It bounds both " + f"{key}: gated on {gates!r}. It bounds both " "personal and group runs, so gating it on one capability hides a live " "limit from administrators who use the other." ) From bb83f2ed176e0cddf863e22357a90ba7f1c83a02 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 11:06:34 -0400 Subject: [PATCH 09/12] test: bind M365 history runtime in group collaboration conversion harness Conversion now publishes Microsoft 365 history and the route catches M365ApprovalRequired/M365PolicyError, so the shared ConversionHarness failed with "History sharing authorization is not configured" and NameError before any conversion assertion ran. - Build a real M365HistoryService from production's nested history callbacks (AST-loaded from configure_m365_history_runtime) over the in-memory stores, scoped to each conversion and restored in finally. - Seed the route's M365 exceptions, strip_pending_action_references, a delivery-cancel recorder and the m365_execution_runs container; loud stubs guard the unreachable M365 publication paths. - Assert the exact delivery-cancel order and add drift tests: every global read by the loaded production code is bound, and the history service does not leak out of a conversion. - ui_tests/test_v2_group_participant_invites.py binds the globals its own served helpers read (real strip_private_chat_checks, loud image stubs) and gets a matching drift test. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ..._group_collaboration_source_storage_fix.py | 165 +++++++++++++++++- ui_tests/test_v2_group_participant_invites.py | 44 ++++- 2 files changed, 198 insertions(+), 11 deletions(-) diff --git a/functional_tests/test_group_collaboration_source_storage_fix.py b/functional_tests/test_group_collaboration_source_storage_fix.py index 4e84e4305..5c9e052b2 100644 --- a/functional_tests/test_group_collaboration_source_storage_fix.py +++ b/functional_tests/test_group_collaboration_source_storage_fix.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional regression for group conversation source storage. -Version: 0.261.106 +Version: 0.261.213 Implemented in: 0.261.024 Ported to the React branch in: 0.261.106 Related issue: microsoft/simplechat#1472 @@ -14,12 +14,17 @@ """ import ast +import builtins from copy import deepcopy from datetime import datetime, timezone +import dis +import functools +import inspect import logging from pathlib import Path import runpy -from types import SimpleNamespace +import sys +from types import CodeType, FunctionType, SimpleNamespace from typing import Any, Dict, Iterable, List, Optional import unittest @@ -33,8 +38,17 @@ APP_ROOT = Path(__file__).resolve().parents[1] / 'application' / 'single_app' +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + +# Test-only path setup precedes these real modules; none of them load config or Azure clients. +import functions_m365_history # noqa: E402 +from functions_m365_action_cards import strip_pending_action_references # noqa: E402 +from functions_m365_approvals import M365ApprovalRequired, M365PolicyError, material_fingerprint # noqa: E402 + COLLABORATION_FILE = APP_ROOT / 'functions_collaboration.py' ROUTE_FILE = APP_ROOT / 'route_backend_collaboration.py' +RUNTIME_FILE = APP_ROOT / 'functions_m365_runtime.py' SOURCE_ID = 'source-conversation' GROUP_ID = 'group-1' OWNER = { @@ -98,11 +112,15 @@ '_collaboration_retention_identity', '_read_collaboration_conversation_for_retention', '_cleanup_collaboration_thoughts', + '_read_linked_collaboration_source', + '_cancel_collaboration_pending_deliveries', '_cleanup_linked_collaboration_source', '_delete_collaboration_conversation_records', 'delete_collaboration_conversation_for_retention', 'delete_personal_collaboration_conversation', } +# Store callbacks nested in production's history runtime setup. +HISTORY_RUNTIME_CALLBACKS = ('read_conversation', 'read_messages', 'audience', 'has_active_request') class StorageFailure(RuntimeError): @@ -216,6 +234,59 @@ def history_fixture(): return list(reversed(messages)) +def fixture_has_no_m365_sources(*args, **kwargs): + raise AssertionError('The fixture history has no Microsoft 365 sources to publish.') + + +# Approval records and evidence publication are unreachable without Microsoft 365 sources. +NO_M365_PUBLICATION = SimpleNamespace( + upsert_item=fixture_has_no_m365_sources, + authorize_sources=fixture_has_no_m365_sources, +) + + +def load_history_runtime_callbacks(namespace): + """Run production's nested history store callbacks against the harness stores.""" + tree = ast.parse(RUNTIME_FILE.read_text(encoding='utf-8'), filename=str(RUNTIME_FILE)) + setup = next(( + node for node in tree.body + if isinstance(node, ast.FunctionDef) and node.name == 'configure_m365_history_runtime' + ), None) + callbacks = [ + node for node in (setup.body if setup else []) + if isinstance(node, ast.FunctionDef) and node.name in HISTORY_RUNTIME_CALLBACKS + ] + found = sorted(node.name for node in callbacks) + if found != sorted(HISTORY_RUNTIME_CALLBACKS): + raise AssertionError( + f'configure_m365_history_runtime should define {sorted(HISTORY_RUNTIME_CALLBACKS)}; found {found}.' + ) + loaded_namespace = dict(namespace) + loaded_namespace.setdefault('__builtins__', __builtins__) + exec(compile(ast.Module(body=callbacks, type_ignores=[]), str(RUNTIME_FILE), 'exec'), loaded_namespace) + return loaded_namespace + + +def global_reads(code): + """Return the global names a compiled function or its nested code reads.""" + names = { + instruction.argval for instruction in dis.get_instructions(code) + if instruction.opname == 'LOAD_GLOBAL' + } + for constant in code.co_consts: + if isinstance(constant, CodeType): + names |= global_reads(constant) + return names + + +def production_functions(namespace): + """Yield the functions compiled from production source into namespace.""" + for name, value in namespace.items(): + function = inspect.unwrap(value) if isinstance(value, FunctionType) else None + if function is not None and function.__globals__ is namespace: + yield name, function + + class ConversionHarness: """Load actual helpers with in-memory storage and controlled external effects.""" @@ -241,6 +312,8 @@ def __init__(self, storage='regular', history=True): if name == 'collaboration_user_state': partition_field = 'user_id' self.containers[name] = TrackingContainer(partition_field) + # Production partitions Microsoft 365 execution runs by user. + self.containers['m365_execution_runs'] = TrackingContainer('user_id') prefix = 'group_' if storage == 'group' else '' self.source = self.containers[f'{prefix}conversations'] self.messages = self.containers[f'{prefix}messages'] @@ -262,14 +335,15 @@ def __init__(self, storage='regular', history=True): self.logs = [] self.thought_cleanup = [] self.blob_cleanup = [] - group_helpers = load_source_members( + self.cancelled_deliveries = [] + self.group_helpers = load_source_members( str(APP_ROOT / 'functions_group.py'), {'get_user_role_in_group', 'assert_group_role', 'check_group_status_allows_operation'}, namespace={'Iterable': Iterable, 'find_group_by_id': self.find_group}, ) namespace = { **MODEL_HELPERS, - **group_helpers, + **self.group_helpers, 'deepcopy': deepcopy, 'datetime': datetime, 'timezone': timezone, @@ -281,7 +355,11 @@ def __init__(self, storage='regular', history=True): 'log_conversation_archival': lambda **kwargs: None, 'log_conversation_deletion': lambda **kwargs: None, 'sync_chat_upload_workspace_document_sharing_for_collaboration': lambda item: None, - '_delete_blob_backed_collaboration_files': lambda messages: self.blob_cleanup.extend( + 'strip_pending_action_references': strip_pending_action_references, + 'cancel_m365_conversation_deliveries': lambda conversation_id: ( + self.cancelled_deliveries.append(conversation_id) + ), + '_delete_blob_backed_collaboration_files': lambda messages, conversation=None: self.blob_cleanup.extend( message['id'] for message in messages ), 'archive_thoughts_for_conversation': lambda conversation_id, user_id, **kwargs: ( @@ -301,10 +379,43 @@ def __init__(self, storage='regular', history=True): assignment_names={'CITATION_TRACKING_CONVERSATION_FIELDS', 'PERSONAL_COLLABORATION_MANAGER_ROLES'}, namespace=namespace, ) + self.history_namespace = load_history_runtime_callbacks({ + **{f'cosmos_{name}_container': container for name, container in self.containers.items()}, + 'assert_group_role': self.group_helpers['assert_group_role'], + 'find_group_by_id': self.find_group, + 'material_fingerprint': material_fingerprint, + }) + self.history_service = functions_m365_history.M365HistoryService( + tenant_id='test-tenant', + jobs=NO_M365_PUBLICATION, + approvals=NO_M365_PUBLICATION, + read_conversation=self.history_namespace['read_conversation'], + read_messages=self.history_namespace['read_messages'], + memory_resolver=fixture_has_no_m365_sources, + audience_resolver=self.history_namespace['audience'], + has_active_request=self.history_namespace['has_active_request'], + ) + for name in ( + 'ensure_group_collaboration_for_legacy_conversation', + 'ensure_personal_collaboration_for_legacy_conversation', + ): + self.namespace[name] = self.with_history_service(self.namespace[name]) def find_group(self, group_id): return deepcopy(self.group) if self.group and self.group['id'] == group_id else None + def with_history_service(self, function): + """Configure the history service only while one conversion runs.""" + @functools.wraps(function) + def convert_with_history_service(*args, **kwargs): + previous_service = functions_m365_history._service + functions_m365_history.configure_m365_history(self.history_service) + try: + return function(*args, **kwargs) + finally: + functions_m365_history.configure_m365_history(previous_service) + return convert_with_history_service + def invalidate(self, item, reason): self.invalidations.append({ 'item': deepcopy(item), @@ -341,6 +452,8 @@ def build_route_app(self): 'request': request, 'get_settings': lambda: {'enable_collaborative_conversations': self.feature_enabled}, 'get_current_user_info': lambda: self.user, + 'M365ApprovalRequired': M365ApprovalRequired, + 'M365PolicyError': M365PolicyError, 'swagger_route': lambda **kwargs: (lambda function: function), 'get_auth_security': lambda: {}, 'login_required': lambda function: function, @@ -764,6 +877,11 @@ def test_manual_and_retention_cleanup_follow_converted_source_links(self): self.assertEqual(set(harness.blob_cleanup), copied_ids | source_message_ids) thought_operation = 'archive' if mode == 'archive' else 'delete' self.assertIn((thought_operation, SOURCE_ID, OWNER['user_id']), harness.thought_cleanup) + # Destination first, then each reciprocally linked source; the decoy is never cancelled. + expected_cancellations = [conversation['id'], backing['id']] + if storage == 'group': + expected_cancellations.append(SOURCE_ID) + self.assertEqual(harness.cancelled_deliveries, expected_cancellations) def test_linked_source_cleanup_keeps_owner_and_backlink_guards(self): for storage in ('regular', 'group'): @@ -785,6 +903,7 @@ def test_linked_source_cleanup_keeps_owner_and_backlink_guards(self): self.assertIsNone(result) self.assertEqual(harness.snapshot(), before) self.assertEqual(harness.messages.calls, message_calls_before) + self.assertFalse(harness.cancelled_deliveries) def test_existing_personal_conversion_is_unchanged(self): harness = ConversionHarness() @@ -897,6 +1016,42 @@ def test_v1_storage_failures_surface_as_safe_500_responses(self): self.assertFalse(harness.events) self.assertTrue(harness.logs) + def test_harness_binds_every_global_the_loaded_production_code_reads(self): + harness = ConversionHarness('group') + harness.build_route_app() + namespaces = { + 'collaboration': harness.namespace, + 'group': harness.group_helpers, + 'history runtime': harness.history_namespace, + 'route': harness.route_namespace, + 'artifact': ARTIFACT_HELPERS, + } + checked = {label: dict(production_functions(namespace)) for label, namespace in namespaces.items()} + unbound = {} + for label, functions in checked.items(): + for name, function in functions.items(): + missing = sorted( + global_reads(function.__code__) - set(namespaces[label]) - set(dir(builtins)) + ) + if missing: + unbound[f'{label}.{name}'] = missing + self.assertEqual( + unbound, {}, + 'Seed these production globals in ConversionHarness, or the code paths that read them ' + 'fail with NameError.', + ) + self.assertIn('ensure_group_collaboration_for_legacy_conversation', checked['collaboration']) + self.assertIn('convert_group_conversation_to_collaboration_api', checked['route']) + self.assertLessEqual(set(HISTORY_RUNTIME_CALLBACKS), set(checked['history runtime'])) + + def test_harness_scopes_the_history_service_to_each_conversion(self): + previous_service = functions_m365_history._service + harness = ConversionHarness() + _, _, created, _ = harness.convert() + restored_service = functions_m365_history._service + self.assertTrue(created) + self.assertIs(restored_service, previous_service, 'The harness leaked its history service.') + if __name__ == '__main__': unittest.main() diff --git a/ui_tests/test_v2_group_participant_invites.py b/ui_tests/test_v2_group_participant_invites.py index c11b664d7..2f0579f45 100644 --- a/ui_tests/test_v2_group_participant_invites.py +++ b/ui_tests/test_v2_group_participant_invites.py @@ -1,7 +1,7 @@ # test_v2_group_participant_invites.py """ Browser regression for React v2 group participant invitations. -Version: 0.261.106 +Version: 0.261.213 Implemented in: 0.261.106 Related issue and backend fix: microsoft/simplechat#1472, #1473. @@ -14,6 +14,7 @@ Run: python .\\ui_tests\\test_v2_group_participant_invites.py """ +import builtins from copy import deepcopy from pathlib import Path import re @@ -23,12 +24,15 @@ import pytest from playwright.sync_api import expect -# Resolve the existing browser and isolated backend harnesses for standalone runs. +# Resolve the existing browser and isolated backend harnesses, and the application +# modules that served messages pass through, for standalone runs. REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "application" / "single_app")) sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures" / "orchestration")) sys.path.insert(0, str(REPO_ROOT / "ui_tests" / "fixtures")) sys.path.insert(0, str(REPO_ROOT / "functional_tests")) import harness_build as hb # noqa: E402 +from functions_chat_content_checks import strip_private_chat_checks # noqa: E402 from playwright_connection import connect_options # noqa: E402,F401 from test_group_collaboration_source_storage_fix import ( # noqa: E402 COLLABORATION_FILE, @@ -38,8 +42,10 @@ SECOND_INVITEE, SOURCE_ID, ConversionHarness, + global_reads, history_fixture, load_source_members, + production_functions, ) @@ -49,6 +55,11 @@ "display_name": "Directory Colleague", "email": "colleague@example.com", } +SERVED_HELPERS = {"serialize_collaboration_message", "_get_collaboration_display_role"} + + +def history_has_no_images(*args, **kwargs): + raise AssertionError("The participant history has no image messages to serialize.") class ParticipantApi: @@ -66,12 +77,18 @@ def __init__(self, storage): self.unexpected = [] self.expected_errors = set() self.fail_member_search = False - helpers = load_source_members( + self.helpers = load_source_members( str(COLLABORATION_FILE), - {"serialize_collaboration_message", "_get_collaboration_display_role"}, - namespace=self.backend.namespace, + SERVED_HELPERS, + namespace={ + **self.backend.namespace, + "strip_private_chat_checks": strip_private_chat_checks, + # The served history is text-only, so image serialization is unreachable. + "build_collaboration_image_url": history_has_no_images, + "_publicize_image_revisions": history_has_no_images, + }, ) - self.serialize_message = helpers["serialize_collaboration_message"] + self.serialize_message = self.helpers["serialize_collaboration_message"] def conversations(self): return [ @@ -334,5 +351,20 @@ def test_personal_invites_keep_the_personal_conversion_path(participant_page): assert not any(path.startswith("/api/groups/") for _, path in api.requests) +def test_participant_api_binds_every_global_its_served_helpers_read(): + api = ParticipantApi("regular") + helpers = dict(production_functions(api.helpers)) + unbound = { + name: sorted(global_reads(function.__code__) - set(api.helpers) - set(dir(builtins))) + for name, function in helpers.items() + } + unbound = {name: names for name, names in unbound.items() if names} + assert set(helpers) == SERVED_HELPERS + assert unbound == {}, ( + "Seed these production globals in ParticipantApi, or serving messages fails with " + f"NameError: {unbound}" + ) + + if __name__ == "__main__": raise SystemExit(pytest.main([__file__, "-q"])) From 82cb3452ab3e96f0f0db10be617f08d79ae16a3d Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 11:21:41 -0400 Subject: [PATCH 10/12] test: expect run-level access loss when a record refresh is revoked test_revoked_record_refresh_clears_the_previous_page looked for the access alert inside the "Complete record inspection" region. Since ad8f42184 a 403 on a paged workflow read calls onAccessLost, and WorkflowRunHistory replaces the whole expanded run with a run-level alert that removes every cached run detail, so that region unmounts and the panel-scoped alert can never appear. Assert the deliberate run-level contract instead, matching test_v2_workflow_publication_completion.py: the 403-specific alert is shown, the queued failure was consumed, and the inspection region, the records list and the previously loaded record text are all gone. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ui_tests/test_v2_workflow_loops.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/ui_tests/test_v2_workflow_loops.py b/ui_tests/test_v2_workflow_loops.py index b3b5f6d3a..8c45a42ef 100644 --- a/ui_tests/test_v2_workflow_loops.py +++ b/ui_tests/test_v2_workflow_loops.py @@ -1,7 +1,7 @@ # test_v2_workflow_loops.py """ Closed browser regressions for serial For each, exact Collect and explicit saved-record reporting. -Version: 0.261.127 +Version: 0.261.213 Implemented in: 0.261.117 Loads the real built local SPA and validates authoring payloads with production @@ -906,13 +906,20 @@ def test_revoked_record_refresh_clears_the_previous_page(workflow_loops_ui): expand_history(ui) page.get_by_role("button", name=f"Show execution attempts for {COLLECT_EXECUTION_ID}", exact=True).click() page.get_by_role("button", name="Load complete records", exact=True).click() - expect(page.get_by_role("list", name="Complete saved records", exact=True)).to_contain_text("Private brief") + records = page.get_by_role("list", name="Complete saved records", exact=True) + expect(records).to_contain_text("Finding from Private brief") ui.reject_next("GET", f"/api/user/workflows/{LOOP_WORKFLOW_ID}/runs/{LOOP_RUN_ID}/executions/{COLLECT_EXECUTION_ID}/attempts/1/records", status=403, error="Source access could not be confirmed.") panel = page.get_by_role("region", name="Complete record inspection", exact=True) panel.get_by_role("button", name="Refresh", exact=True).click() - expect(panel.get_by_role("alert").filter(has_text="no longer have access")).to_be_visible() - expect(page.get_by_role("list", name="Complete saved records", exact=True)).to_have_count(0) + # Lost access is escalated to the run, which removes every cached run detail, not only this page. + access_lost = page.get_by_role("alert").filter(has_text="Cached run details were removed") + expect(access_lost).to_be_visible() + expect(access_lost).to_contain_text("You no longer have access to this workflow run.") + assert not ui.failures, "The records refresh never received the revoked-access response." + expect(panel).to_have_count(0) + expect(records).to_have_count(0) + expect(page.get_by_text("Finding from Private brief", exact=False)).to_have_count(0) @pytest.mark.parametrize("group", [False, True]) From ddb2d336250a6f26d85269e438fc8b5a969687b9 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 12:00:42 -0400 Subject: [PATCH 11/12] test(auto-open): yield the harness page only when it opened CodeQL (py/uninitialized-local-variable) flagged the module fixture's `yield page` on #1598. It does not model pytest.skip() as non-returning, so it saw a path from the HarnessUnavailable handler to the yield with `page` unbound. Moving the yield into the try statement's else clause makes the control flow explicit with no change in behaviour: the page is yielded only when the harness opened, and a missing v2_ui toolchain still skips the four browser checks. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ui_tests/test_v2_orchestration_auto_open.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui_tests/test_v2_orchestration_auto_open.py b/ui_tests/test_v2_orchestration_auto_open.py index 54454ca12..89d5b374f 100644 --- a/ui_tests/test_v2_orchestration_auto_open.py +++ b/ui_tests/test_v2_orchestration_auto_open.py @@ -222,7 +222,8 @@ def orchestration_page(): page = stack.enter_context(hb.harness_page(collect_errors=errors)) except hb.HarnessUnavailable as exc: pytest.skip(f"skipped the browser-driven checks: {exc}") - yield page + else: + yield page _report_page_errors(errors) From bf29405748452d1f13f83541f58339de02fc8399 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 1 Oct 2026 13:11:47 -0400 Subject: [PATCH 12/12] Bind 6a collaboration helpers in the conversion harness #1592 (6a) moved the summary copy in both legacy conversions into _carry_summary_into_collaboration, and made _copy_legacy_personal_messages_to_collaboration withhold workflow-result messages through functions_workflow_result_masking. The ConversionHarness closed namespace bound none of these names, so every conversion raised NameError (script: failures=8, errors=58; pytest: 66 failed). Load _carry_summary_into_collaboration from production with the other collaboration functions, and bind the real masking helpers, whose module has no application imports. No production statements are copied. The file is back to its pre-merge result: only the 4 known non_group_invitee subtests fail, in all four modes. The existing drift test names each unbound global, and removing any of the three bindings fails it. Refs #1543 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../test_group_collaboration_source_storage_fix.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/functional_tests/test_group_collaboration_source_storage_fix.py b/functional_tests/test_group_collaboration_source_storage_fix.py index 5c9e052b2..8b2872664 100644 --- a/functional_tests/test_group_collaboration_source_storage_fix.py +++ b/functional_tests/test_group_collaboration_source_storage_fix.py @@ -2,7 +2,7 @@ #!/usr/bin/env python3 """ Functional regression for group conversation source storage. -Version: 0.261.213 +Version: 0.261.214 Implemented in: 0.261.024 Ported to the React branch in: 0.261.106 Related issue: microsoft/simplechat#1472 @@ -45,6 +45,10 @@ import functions_m365_history # noqa: E402 from functions_m365_action_cards import strip_pending_action_references # noqa: E402 from functions_m365_approvals import M365ApprovalRequired, M365PolicyError, material_fingerprint # noqa: E402 +from functions_workflow_result_masking import ( # noqa: E402 + message_uses_workflow_result, + withhold_workflow_result_message, +) COLLABORATION_FILE = APP_ROOT / 'functions_collaboration.py' ROUTE_FILE = APP_ROOT / 'route_backend_collaboration.py' @@ -97,6 +101,7 @@ 'get_personal_collaboration_conversation_by_source_conversation', '_is_eligible_legacy_personal_conversation', '_copy_legacy_personal_messages_to_collaboration', + '_carry_summary_into_collaboration', 'ensure_personal_collaboration_for_legacy_conversation', '_is_eligible_legacy_group_conversation', '_copy_legacy_group_messages_to_collaboration', @@ -356,6 +361,8 @@ def __init__(self, storage='regular', history=True): 'log_conversation_deletion': lambda **kwargs: None, 'sync_chat_upload_workspace_document_sharing_for_collaboration': lambda item: None, 'strip_pending_action_references': strip_pending_action_references, + 'message_uses_workflow_result': message_uses_workflow_result, + 'withhold_workflow_result_message': withhold_workflow_result_message, 'cancel_m365_conversation_deliveries': lambda conversation_id: ( self.cancelled_deliveries.append(conversation_id) ),