diff --git a/python/reference_integrations/openwebui_pipe/README.md b/python/reference_integrations/openwebui_pipe/README.md index d45b6b4..bef080c 100644 --- a/python/reference_integrations/openwebui_pipe/README.md +++ b/python/reference_integrations/openwebui_pipe/README.md @@ -40,7 +40,7 @@ shapes the downstream request. ## Files - `open_webui_pipe.py`: basic integration, no Directive Drafter layer (recommended/default). -- `open_webui_pipe_with_directive_drafter.py`: optional/experimental Directive Drafter layer (heuristic check first, then optional model fallback) before `engine.step(...)`. +- `open_webui_pipe_with_directive_drafter.py`: optional Directive Drafter layer (heuristic check first, then optional model fallback) before `engine.step(...)`. ## Setup @@ -119,7 +119,7 @@ Use this pipe when you want the simplest Open WebUI integration path. Suggested verification: -- Send `use docker` and confirm you get `State updated: Use docker.` with trace showing a local turn +- Send `use docker` and confirm the native OpenWebUI confirmation dialog appears first; after approval, confirm `State updated: Use docker.` with trace showing a local turn - Send a normal prompt such as `what should I run?` and confirm trace shows a forwarded turn with compiler state included - Send `use docker`, then `prohibit docker`, and confirm Open WebUI rejects the second request instead of changing state - Optionally send `show state` and confirm the state summary is returned locally @@ -133,6 +133,18 @@ Advanced check: Use this pipe when you want the same runtime behavior plus Directive Drafter preprocessing. +When the drafter produces a `CanonicalDirective`, the pipe uses Open WebUI's +native `__event_call__` confirmation dialog for HITL approval. The lifecycle is: + +1. Directive Drafter drafts a canonical directive. +2. Open WebUI asks the user to confirm the drafted directive. +3. An approved directive is applied through the Context Compiler `Engine` path. +4. A rejected directive is discarded without changing authoritative state. + +The confirmation is request-scoped. It remains active while the current +Open WebUI request waits for the user's response, but it is not durable pending +state and does not survive an Open WebUI server restart. + Suggested verification: - Send `use docker` and confirm you get `State updated: Use docker.` with trace showing a local turn @@ -140,6 +152,8 @@ Suggested verification: - Send `please use docker` and confirm either: - the Directive Drafter converts it into a local state update, or - trace shows the turn followed the normal compiler path without a silent state change +- When a canonical directive is drafted, confirm the native Open WebUI approval dialog; accepting it returns `State updated.` and `show state` reports `Use: docker`. +- Repeat the draft flow and reject it in the native confirmation dialog; confirm the pipe returns `Directive discarded. No state change was applied.` and `show state` remains unchanged. - Send `please use docker`, then `prohibit docker`, and confirm the second request is rejected without creating resumable state - Send `use docker and prohibit peanuts` and confirm the pipe responds locally that multiple directives are not supported and must be submitted separately - Send a normal prompt such as `what should I run?` and confirm trace shows a forwarded turn with compiler state included @@ -148,24 +162,28 @@ Advanced check: - If you have a local proxy or stub, inspect the forwarded request and confirm it contains exactly one `[[cc_state]]` system message reflecting the active state. - Confirm that saved premise appears as `Premise: ...` and saved policy appears as `Use: ...` or `Prohibit: ...`. +- Confirm that the approval prompt is a native Open WebUI confirmation dialog and that no approval marker or pending directive is added to the conversation transcript. ### Optional extra checks If you want a slightly broader manual pass: - verify chat isolation with separate real chat ids -- verify state is lost after restart because these examples do not use external persistence +- verify the native confirmation flow does not depend on pipe object lifetime +- verify authoritative engine state is lost after restart because these examples do not use external persistence - verify non-text input is bypassed ### Notes - Trace is the easiest way to verify behavior from the Open WebUI chat output. +- Native approval requires an active Open WebUI browser session because `__event_call__` waits for the confirmation response during the current request. - Forwarded-request inspection is optional and most useful when you already have a local proxy or stub. - Exact `show state` is a local-state check and does not rely on trace output. ## Limits -- No durable external persistence +- Approval state is held only for the active native confirmation call; no pending approval state is stored in chat content +- Authoritative state does not survive a server restart; it remains in-memory because these examples have no durable external persistence - No multi-worker or cross-process guarantees - No Redis, DB, or external storage for checkpoints - No Filters or Pipelines @@ -215,11 +233,10 @@ If you want a slightly broader manual pass: ## Compatibility -Tested target: Open WebUI `v0.8.12`. -Validated at runtime on stock Docker Open WebUI with a real backend model provider. - -Compatibility note: OpenWebUI `0.9.x` changed `Users.get_user_by_id` to async. -These examples support both sync (`0.8.x`) and async (`0.9.x`) user lookup. +Tested and validated at runtime on Open WebUI `v0.11.0` with a real Ollama +backend model provider. Both the basic Pipe and Directive Drafter Pipe loaded +and completed live requests, including native confirmation approval and +rejection flows. ## Troubleshooting diff --git a/python/reference_integrations/openwebui_pipe/open_webui_pipe.py b/python/reference_integrations/openwebui_pipe/open_webui_pipe.py index 42f45d8..cef5dd3 100644 --- a/python/reference_integrations/openwebui_pipe/open_webui_pipe.py +++ b/python/reference_integrations/openwebui_pipe/open_webui_pipe.py @@ -2,7 +2,7 @@ title: Context Compiler Open WebUI Pipe author: rlippmann author_url: https://github.com/rlippmann/context-compiler-example-integrations -version: 0.9.4 +version: 0.10.0 requirements: context-compiler>=0.9.0dev13 Minimal Open WebUI Pipe integration for Context Compiler. diff --git a/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py b/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py index e4e404a..42c89eb 100644 --- a/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py +++ b/python/reference_integrations/openwebui_pipe/open_webui_pipe_with_directive_drafter.py @@ -2,7 +2,7 @@ title: Context Compiler Open WebUI Pipe (Directive Drafter) author: rlippmann author_url: https://github.com/rlippmann/context-compiler-example-integrations -version: 0.9.4 +version: 0.10.0 requirements: context-compiler>=0.9.0dev13, context-compiler-directive-drafter>=0.2.0dev2 Open WebUI integration with Context Compiler directive drafter. @@ -15,8 +15,8 @@ calling `engine.step(...)` Core decision handling remains the same as the base integration. -Failed transitions are rejected for the current request and do not leave -resumable in-memory engine state behind. +Pending approvals use Open WebUI's native confirmation event and do not add +state to the conversation transcript. """ import inspect @@ -50,7 +50,6 @@ def Field(*, default: Any, description: str = "") -> Any: # type: ignore[no-red from context_compiler import ( Decision, DecisionKind, - DECISION_UPDATE, POLICY_PROHIBIT, POLICY_USE, Engine, @@ -69,7 +68,6 @@ def Field(*, default: Any, description: str = "") -> Any: # type: ignore[no-red _CC_MARKER = "[[cc_state]]" _ENGINES_BY_CHAT_KEY: dict[str, Engine] = {} -_PENDING_PROPOSALS_BY_CHAT_KEY: dict[str, str] = {} class _EngineSnapshot(TypedDict): @@ -77,16 +75,16 @@ class _EngineSnapshot(TypedDict): policies: dict[str, PolicyValue] -def _is_explicit_approval(message: str) -> bool: - return message.strip().lower() in {"y", "yes"} - - -def _is_explicit_rejection(message: str) -> bool: - return message.strip().lower() in {"n", "no"} - - -def _render_proposal_prompt(directive_text: str) -> str: - return f"This is what I think the directive is:\n{directive_text}\nApply it? (y/n)" +def _confirmation_was_accepted(response: object) -> bool: + if isinstance(response, bool): + return response + if isinstance(response, dict): + for key in ("confirmed", "approved", "value"): + if key in response: + return _confirmation_was_accepted(response[key]) + if isinstance(response, str): + return response.strip().lower() in {"y", "yes", "true", "1", "ok"} + return False def _resolve_chat_key( @@ -749,6 +747,71 @@ async def _forward_passthrough( return normalized_error return response + async def _apply_approved_directive( + self, + directive: CanonicalDirective, + *, + body: dict[str, Any], + user_payload: dict[str, Any], + request: Request, + base_model_id: str | None, + chat_key: str, + original_input: str, + engine: Engine, + ) -> Any: + state_before = _snapshot_engine_state(engine) + engine_snapshot_json = engine.export_json() + compile_input = directive.text + logger.debug("preprocessor: approved_input=%r", compile_input) + decision = engine.apply_directive(directive) + state_after = _snapshot_engine_state(engine) + + if decision.kind == DecisionKind.ERROR: + _ENGINES_BY_CHAT_KEY[chat_key] = _restore_engine_from_snapshot( + engine_snapshot_json + ) + return self._with_trace( + decision.message, + original_input=original_input, + compiler_input=compile_input, + decision=decision, + state_before=state_before, + state_after=state_after, + preprocessor_output=compile_input, + llm_called=False, + ) + if decision.kind == DecisionKind.UPDATE: + return self._with_trace( + "State updated.", + original_input=original_input, + compiler_input=compile_input, + decision=decision, + state_before=state_before, + state_after=state_after, + preprocessor_output=compile_input, + llm_called=False, + ) + + state_injected = "yes" if _has_non_empty_authoritative_state(engine) else "no" + response = await self._forward_passthrough( + body, + user_payload, + request, + base_model_id=base_model_id, + engine=engine, + ) + return self._with_trace( + response, + original_input=original_input, + compiler_input=compile_input, + decision=decision, + state_before=state_before, + state_after=state_after, + preprocessor_output=compile_input, + llm_called=base_model_id is not None, + state_injected=state_injected, + ) + async def pipe( self, body: dict[str, Any], @@ -756,6 +819,7 @@ async def pipe( __request__: Request, __chat_id__: str | None = None, __metadata__: dict[str, Any] | None = None, + __event_call__: Any | None = None, ) -> Any: # Open WebUI integration entrypoint: # 1) extract latest user input @@ -822,100 +886,6 @@ async def pipe( if latest_user_text.strip().lower() == "show state": return _render_show_state_summary(engine) - pending_proposal = _PENDING_PROPOSALS_BY_CHAT_KEY.get(chat_key) - if pending_proposal is not None: - if _is_explicit_approval(latest_user_text): - del _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] - state_before = _snapshot_engine_state(engine) - engine_snapshot_json = engine.export_json() - compile_input = pending_proposal - logger.debug("preprocessor: approved_pending_input=%r", compile_input) - decision = engine.step(compile_input) - if decision.kind == DecisionKind.ERROR: - kind = DecisionKind.ERROR.value - elif decision.kind == DecisionKind.UPDATE: - kind = DECISION_UPDATE - else: - kind = DecisionKind.NO_DIRECTIVE.value - logger.debug("preprocessor: decision=%s", kind) - state_after = _snapshot_engine_state(engine) - - if decision.kind == DecisionKind.ERROR: - _ENGINES_BY_CHAT_KEY[chat_key] = _restore_engine_from_snapshot( - engine_snapshot_json - ) - return self._with_trace( - decision.message - if decision.kind == DecisionKind.ERROR - else None or "", - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=False, - ) - if decision.kind == DecisionKind.NO_DIRECTIVE: - state_injected = ( - "yes" if _has_non_empty_authoritative_state(engine) else "no" - ) - response = await self._forward_passthrough( - body, - __user__, - __request__, - base_model_id=base_model_id, - engine=engine, - ) - return self._with_trace( - response, - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=base_model_id is not None, - state_injected=state_injected, - ) - if decision.kind == DecisionKind.UPDATE: - return self._with_trace( - "State updated.", - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=False, - ) - - state_injected = ( - "yes" if _has_non_empty_authoritative_state(engine) else "no" - ) - response = await self._forward_passthrough( - body, - __user__, - __request__, - base_model_id=base_model_id, - engine=engine, - ) - return self._with_trace( - response, - original_input=latest_user_text, - compiler_input=compile_input, - decision=decision, - state_before=state_before, - state_after=state_after, - preprocessor_output=compile_input, - llm_called=base_model_id is not None, - state_injected=state_injected, - ) - if _is_explicit_rejection(latest_user_text): - del _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] - return "Directive discarded. No state change was applied." - del _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] - state_before = _snapshot_engine_state(engine) preprocess_error: str | None = None drafted_result, preprocess_error = await self._preprocess_user_input( @@ -952,6 +922,34 @@ async def pipe( state_injected=state_injected, ) - compile_input = drafted_result.result.text - _PENDING_PROPOSALS_BY_CHAT_KEY[chat_key] = compile_input - return _render_proposal_prompt(compile_input) + if __event_call__ is None: + return ( + "Context Compiler pipe misconfigured: Open WebUI confirmation support " + "(__event_call__) is required for Directive Drafter approvals." + ) + + approval_response = await __event_call__( + { + "type": "confirmation", + "data": { + "title": "Approve directive", + "message": ( + "This is what I think the directive is:\n" + f"{drafted_result.result.text}\n\nApply it?" + ), + }, + } + ) + if not _confirmation_was_accepted(approval_response): + return "Directive discarded. No state change was applied." + + return await self._apply_approved_directive( + drafted_result.result, + body=body, + user_payload=__user__, + request=__request__, + base_model_id=base_model_id, + chat_key=chat_key, + original_input=latest_user_text, + engine=engine, + ) diff --git a/python/tests/test_openwebui_pipe_with_directive_drafter.py b/python/tests/test_openwebui_pipe_with_directive_drafter.py index b4d315b..46671b3 100644 --- a/python/tests/test_openwebui_pipe_with_directive_drafter.py +++ b/python/tests/test_openwebui_pipe_with_directive_drafter.py @@ -89,11 +89,67 @@ def _guarded_import( module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) module._ENGINES_BY_CHAT_KEY.clear() - module._PENDING_PROPOSALS_BY_CHAT_KEY.clear() return module -def test_canonical_draft_creates_approval_prompt_and_does_not_mutate_state( +def test_native_confirmation_applies_canonical_draft(monkeypatch) -> None: + module = _load_module("owui_with_drafter_native_confirmation", monkeypatch) + + async def fake_draft(*args, **kwargs): + return DraftResult( + source="test", + result=CanonicalDirective( + kind=DirectiveKind.USE_ITEM, + operands=MappingProxyType({"item": "docker"}), + ), + ) + + confirmation_events: list[dict[str, object]] = [] + + async def confirm(event: dict[str, object]) -> bool: + confirmation_events.append(event) + return True + + monkeypatch.setattr(module.Pipe, "_draft_user_input", fake_draft) + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + chat_id = "chat-native-confirmation" + + result = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "please use docker"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + __event_call__=confirm, + ) + ) + + assert result == "State updated." + assert len(confirmation_events) == 1 + assert confirmation_events[0]["type"] == "confirmation" + assert "use docker" in str(confirmation_events[0]) + assert "cc_pending_directive" not in str(confirmation_events[0]) + + show_state = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "show state"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert show_state == "Premise: none\nUse: docker\nProhibit: none" + + +def test_confirmation_dialog_does_not_write_marker_to_output( monkeypatch, ) -> None: module = _load_module("owui_with_drafter_before_step", monkeypatch) @@ -114,6 +170,9 @@ async def fake_draft(*args, **kwargs): pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" chat_id = "chat-before-step" + async def confirm(event: dict[str, object]) -> bool: + return False + result = asyncio.run( pipe.pipe( { @@ -123,6 +182,7 @@ async def fake_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__=chat_id, + __event_call__=confirm, ) ) show_state = asyncio.run( @@ -137,26 +197,25 @@ async def fake_draft(*args, **kwargs): ) ) - assert result == ( - "This is what I think the directive is:\nuse docker\nApply it? (y/n)" - ) + assert result == "Directive discarded. No state change was applied." + assert "cc_pending_directive" not in result assert show_state == "Premise: none\nUse: none\nProhibit: none" -def test_approval_applies_stored_directive(monkeypatch) -> None: +def test_approval_applies_directive_through_engine_path(monkeypatch) -> None: module = _load_module("owui_with_drafter_failed_transition_followup", monkeypatch) compile_inputs: list[str] = [] real_Engine = module.Engine def Engine_with_tracking(): engine = real_Engine() - original_step = engine.step + original_apply_directive = engine.apply_directive - def tracked_step(user_input: str): - compile_inputs.append(user_input) - return original_step(user_input) + def tracked_apply_directive(directive): + compile_inputs.append(directive.text) + return original_apply_directive(directive) - engine.step = tracked_step + engine.apply_directive = tracked_apply_directive return engine monkeypatch.setattr(module, "Engine", Engine_with_tracking) @@ -174,7 +233,11 @@ async def update_draft(*args, **kwargs): ) monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) - seed = asyncio.run( + + async def confirm(event: dict[str, object]) -> bool: + return True + + result = asyncio.run( pipe.pipe( { "model": "pipe-model", @@ -183,14 +246,7 @@ async def update_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-failed-transition", - ) - ) - follow_up = asyncio.run( - pipe.pipe( - {"model": "pipe-model", "messages": [{"role": "user", "content": "y"}]}, - __user__={"id": "u1"}, - __request__=object(), - __chat_id__="chat-failed-transition", + __event_call__=confirm, ) ) show_state = asyncio.run( @@ -205,24 +261,79 @@ async def update_draft(*args, **kwargs): ) ) - assert seed == ( - "This is what I think the directive is:\nuse docker\nApply it? (y/n)" - ) - assert follow_up == "State updated." + assert result == "State updated." assert show_state == "Premise: none\nUse: docker\nProhibit: none" assert compile_inputs == ["use docker"] - second_follow_up = asyncio.run( - pipe.pipe( - {"model": "pipe-model", "messages": [{"role": "user", "content": "yes"}]}, + assert compile_inputs == ["use docker"] + + +def test_confirmation_flow_does_not_depend_on_pipe_object_lifetime(monkeypatch) -> None: + module = _load_module("owui_with_drafter_pending_lifecycle", monkeypatch) + + async def update_draft(*args, **kwargs): + return DraftResult( + source="test", + result=CanonicalDirective( + kind=DirectiveKind.USE_ITEM, + operands=MappingProxyType({"item": "docker"}), + ), + ) + + monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) + + async def confirm(event: dict[str, object]) -> bool: + return True + + first_pipe = module.Pipe() + first_pipe.valves.BASE_MODEL_ID = "base-model" + first_pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + chat_id = "chat-pending-lifecycle" + first_result = asyncio.run( + first_pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "please use docker"}], + }, __user__={"id": "u1"}, __request__=object(), - __chat_id__="chat-failed-transition", + __chat_id__=chat_id, + __event_call__=confirm, ) ) - assert second_follow_up != "State updated." - assert compile_inputs == ["use docker"] + # Simulate Open WebUI replacing the pipe object between independent requests. + module._ENGINES_BY_CHAT_KEY.clear() + second_pipe = module.Pipe() + second_pipe.valves.BASE_MODEL_ID = "base-model" + second_pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + second_result = asyncio.run( + second_pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "please use docker"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + __event_call__=confirm, + ) + ) + + assert first_result == "State updated." + assert second_result == "State updated." + state = asyncio.run( + second_pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "show state"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert state == "Premise: none\nUse: docker\nProhibit: none" def test_rejection_does_not_mutate_state(monkeypatch) -> None: @@ -243,7 +354,11 @@ async def update_draft(*args, **kwargs): ) monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) - asyncio.run( + + async def reject(event: dict[str, object]) -> bool: + return False + + rejected = asyncio.run( pipe.pipe( { "model": "pipe-model", @@ -252,14 +367,7 @@ async def update_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-state-preserved", - ) - ) - rejected = asyncio.run( - pipe.pipe( - {"model": "pipe-model", "messages": [{"role": "user", "content": "n"}]}, - __user__={"id": "u1"}, - __request__=object(), - __chat_id__="chat-state-preserved", + __event_call__=reject, ) ) @@ -290,7 +398,7 @@ async def update_draft(*args, **kwargs): assert after_rejection != "State updated." -def test_pending_approval_does_not_affect_show_state(monkeypatch) -> None: +def test_rejected_confirmation_does_not_affect_show_state(monkeypatch) -> None: module = _load_module("owui_with_drafter_pending_show_state", monkeypatch) pipe = module.Pipe() pipe.valves.BASE_MODEL_ID = "base-model" @@ -306,7 +414,11 @@ async def update_draft(*args, **kwargs): ) monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) - asyncio.run( + + async def reject(event: dict[str, object]) -> bool: + return False + + result = asyncio.run( pipe.pipe( { "model": "pipe-model", @@ -315,6 +427,7 @@ async def update_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-pending-show-state", + __event_call__=reject, ) ) @@ -330,10 +443,12 @@ async def update_draft(*args, **kwargs): ) ) + assert result == "Directive discarded. No state change was applied." + assert "cc_pending_directive" not in result assert show_state == "Premise: none\nUse: none\nProhibit: none" -def test_unrelated_follow_up_while_pending_does_not_apply_proposal(monkeypatch) -> None: +def test_rejected_confirmation_does_not_apply_follow_up(monkeypatch) -> None: module = _load_module("owui_with_drafter_pending_unrelated_followup", monkeypatch) compile_inputs: list[str] = [] forwarded: list[dict[str, object]] = [] @@ -378,6 +493,10 @@ async def no_draft(*args, **kwargs): ) monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) + + async def reject(event: dict[str, object]) -> bool: + return False + proposal = asyncio.run( pipe.pipe( { @@ -387,28 +506,33 @@ async def no_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-pending-unrelated-followup", + __event_call__=reject, ) ) monkeypatch.setattr(module.Pipe, "_draft_user_input", no_draft) follow_up = asyncio.run( pipe.pipe( - {"model": "pipe-model", "messages": [{"role": "user", "content": "hello"}]}, + { + "model": "pipe-model", + "messages": [ + {"role": "assistant", "content": proposal}, + {"role": "user", "content": "hello"}, + ], + }, __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-pending-unrelated-followup", ) ) - assert proposal == ( - "This is what I think the directive is:\nuse docker\nApply it? (y/n)" - ) + assert proposal == "Directive discarded. No state change was applied." assert follow_up == {"choices": [{"message": {"content": "downstream"}}]} assert compile_inputs == [] assert len(forwarded) == 1 -def test_no_stale_pending_proposal_remains_after_non_approval_response( +def test_rejected_confirmation_does_not_leave_state( monkeypatch, ) -> None: module = _load_module("owui_with_drafter_no_stale_pending", monkeypatch) @@ -455,7 +579,11 @@ async def no_draft(*args, **kwargs): ) monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) - asyncio.run( + + async def reject(event: dict[str, object]) -> bool: + return False + + proposal = asyncio.run( pipe.pipe( { "model": "pipe-model", @@ -464,13 +592,20 @@ async def no_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-no-stale-pending", + __event_call__=reject, ) ) monkeypatch.setattr(module.Pipe, "_draft_user_input", no_draft) - asyncio.run( + follow_up = asyncio.run( pipe.pipe( - {"model": "pipe-model", "messages": [{"role": "user", "content": "hello"}]}, + { + "model": "pipe-model", + "messages": [ + {"role": "assistant", "content": proposal}, + {"role": "user", "content": "hello"}, + ], + }, __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-no-stale-pending", @@ -478,7 +613,18 @@ async def no_draft(*args, **kwargs): ) later_yes = asyncio.run( pipe.pipe( - {"model": "pipe-model", "messages": [{"role": "user", "content": "yes"}]}, + { + "model": "pipe-model", + "messages": [ + {"role": "assistant", "content": proposal}, + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": follow_up["choices"][0]["message"]["content"], + }, + {"role": "user", "content": "yes"}, + ], + }, __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-no-stale-pending", @@ -554,6 +700,10 @@ async def update_draft(*args, **kwargs): ) monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) + + async def confirm(event: dict[str, object]) -> bool: + return True + proposal = asyncio.run( pipe.pipe( { @@ -563,6 +713,7 @@ async def update_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__="chat-update", + __event_call__=confirm, ) ) @@ -587,9 +738,7 @@ async def no_draft(*args, **kwargs): ) ) - assert proposal == ( - "This is what I think the directive is:\nuse docker\nApply it? (y/n)" - ) + assert proposal == "State updated." assert passthrough == {"choices": [{"message": {"content": "downstream"}}]} assert len(forwarded) == 1 @@ -730,7 +879,11 @@ async def update_draft(*args, **kwargs): ) monkeypatch.setattr(module.Pipe, "_draft_user_input", update_draft) - asyncio.run( + + async def confirm(event: dict[str, object]) -> bool: + return True + + proposal = asyncio.run( pipe.pipe( { "model": "pipe-model", @@ -739,16 +892,10 @@ async def update_draft(*args, **kwargs): __user__={"id": "u1"}, __request__=object(), __chat_id__=chat_id, + __event_call__=confirm, ) ) - asyncio.run( - pipe.pipe( - {"model": "pipe-model", "messages": [{"role": "user", "content": "y"}]}, - __user__={"id": "u1"}, - __request__=object(), - __chat_id__=chat_id, - ) - ) + assert proposal == "State updated." async def no_draft(*args, **kwargs): return DraftResult(