From 144a0774a603a457648618398e0c070f11ffb690 Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 26 May 2026 03:18:10 -0400 Subject: [PATCH 1/2] fix: inject state on passthrough --- .../integrations/openwebui/open_webui_pipe.py | 38 ++++++++-- .../open_webui_pipe_with_preprocessor.py | 31 +++++++- src/context_compiler/observability.py | 2 +- tests/test_openwebui_pipe.py | 58 +++++++++++++++ tests/test_openwebui_preprocessor_pipe.py | 74 +++++++++++++++++++ 5 files changed, 194 insertions(+), 9 deletions(-) diff --git a/examples/integrations/openwebui/open_webui_pipe.py b/examples/integrations/openwebui/open_webui_pipe.py index bf12711..b0bd628 100644 --- a/examples/integrations/openwebui/open_webui_pipe.py +++ b/examples/integrations/openwebui/open_webui_pipe.py @@ -193,6 +193,12 @@ def _normalize_state(value: object) -> State: return {"premise": None, "policies": {}, "version": 2} +def _has_non_empty_authoritative_state(state: State) -> bool: + if get_premise_value(state) is not None: + return True + return bool(get_policy_items(state, "use") or get_policy_items(state, "prohibit")) + + def _build_compact_trace_text( *, decision: object, @@ -475,15 +481,27 @@ async def _forward_passthrough( body: dict[str, Any], user_payload: dict[str, Any], request: Request, + *, + state: State | None = None, ) -> Any: - """Forward with a shallow body copy and model override only.""" + """Forward with model override and optional compiler-owned state injection.""" payload = {**body} payload["model"] = self.valves.BASE_MODEL_ID raw_messages = body.get("messages") - if isinstance(raw_messages, list): - payload["messages"] = _strip_trace_blocks_from_messages( + messages = ( + _strip_trace_blocks_from_messages( [msg for msg in raw_messages if isinstance(msg, dict)] ) + if isinstance(raw_messages, list) + else [] + ) + if state is not None and _has_non_empty_authoritative_state(state): + payload["messages"] = _replace_compiler_system_message( + messages, + _render_compiler_state_block(state), + ) + elif isinstance(raw_messages, list): + payload["messages"] = messages user = Users.get_user_by_id(user_payload["id"]) if inspect.isawaitable(user): user = await user @@ -625,7 +643,11 @@ async def pipe( llm_called=False, ) if kind == DECISION_PASSTHROUGH: - response = await self._forward_passthrough(body, __user__, __request__) + compiled_state = _normalize_state(state_after) + state_injected = "yes" if _has_non_empty_authoritative_state(compiled_state) else "no" + response = await self._forward_passthrough( + body, __user__, __request__, state=compiled_state + ) return self._with_trace( response, original_input=latest_user_text, @@ -634,6 +656,7 @@ async def pipe( state_before=state_before, state_after=state_after, llm_called=True, + state_injected=state_injected, ) if kind == DECISION_UPDATE: _CHECKPOINTS_BY_CHAT_KEY[chat_key] = engine.export_checkpoint_json() @@ -647,7 +670,11 @@ async def pipe( llm_called=False, ) - response = await self._forward_passthrough(body, __user__, __request__) + compiled_state = _normalize_state(state_after) + state_injected = "yes" if _has_non_empty_authoritative_state(compiled_state) else "no" + response = await self._forward_passthrough( + body, __user__, __request__, state=compiled_state + ) return self._with_trace( response, original_input=latest_user_text, @@ -656,4 +683,5 @@ async def pipe( state_before=state_before, state_after=state_after, llm_called=True, + state_injected=state_injected, ) diff --git a/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py b/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py index 86568bd..971584d 100644 --- a/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py +++ b/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py @@ -1,5 +1,5 @@ """ -title: Context Compiler Preprocessor Pipe +title: Context Compiler Pipe (Preprocessor) author: rlippmann author_url: https://github.com/rlippmann/context-compiler funding_url: https://github.com/rlippmann/context-compiler @@ -198,6 +198,12 @@ def _normalize_state(value: object) -> State: return {"premise": None, "policies": {}, "version": 2} +def _has_non_empty_authoritative_state(state: State) -> bool: + if get_premise_value(state) is not None: + return True + return bool(get_policy_items(state, "use") or get_policy_items(state, "prohibit")) + + def _build_compact_trace_text( *, decision: object, @@ -693,6 +699,7 @@ async def _forward_passthrough( request: Request, *, base_model_id: str | None, + state: State | None = None, ) -> Any: if base_model_id is None: if self._allow_missing_base_model_for_debug(): @@ -707,10 +714,20 @@ async def _forward_passthrough( payload = {**body} payload["model"] = base_model_id raw_messages = body.get("messages") - if isinstance(raw_messages, list): - payload["messages"] = _strip_trace_blocks_from_messages( + messages = ( + _strip_trace_blocks_from_messages( [msg for msg in raw_messages if isinstance(msg, dict)] ) + if isinstance(raw_messages, list) + else [] + ) + if state is not None and _has_non_empty_authoritative_state(state): + payload["messages"] = _replace_compiler_system_message( + messages, + _render_compiler_state_block(state), + ) + elif isinstance(raw_messages, list): + payload["messages"] = messages user = Users.get_user_by_id(user_payload["id"]) if inspect.isawaitable(user): user = await user @@ -906,11 +923,14 @@ async def pipe( llm_called=False, ) if kind == DECISION_PASSTHROUGH: + compiled_state = _normalize_state(state_after) + state_injected = "yes" if _has_non_empty_authoritative_state(compiled_state) else "no" response = await self._forward_passthrough( body, __user__, __request__, base_model_id=base_model_id, + state=compiled_state, ) return self._with_trace( response, @@ -921,6 +941,7 @@ async def pipe( state_after=state_after, preprocessor_output=preprocessd, llm_called=base_model_id is not None, + state_injected=state_injected, ) if kind == DECISION_UPDATE: _CHECKPOINTS_BY_CHAT_KEY[chat_key] = engine.export_checkpoint_json() @@ -935,11 +956,14 @@ async def pipe( llm_called=False, ) + compiled_state = _normalize_state(state_after) + state_injected = "yes" if _has_non_empty_authoritative_state(compiled_state) else "no" response = await self._forward_passthrough( body, __user__, __request__, base_model_id=base_model_id, + state=compiled_state, ) return self._with_trace( response, @@ -950,4 +974,5 @@ async def pipe( state_after=state_after, preprocessor_output=preprocessd, llm_called=base_model_id is not None, + state_injected=state_injected, ) diff --git a/src/context_compiler/observability.py b/src/context_compiler/observability.py index 8d0f88a..cbdd673 100644 --- a/src/context_compiler/observability.py +++ b/src/context_compiler/observability.py @@ -162,7 +162,7 @@ def build_compact_trace_text( lines.append(f"active state: {_active_state_summary(state_after)}") lines.append(f"downstream LLM call: {'yes' if llm_called else 'no'}") - lines.append("state injected: no") + lines.append(f"state injected: {state_injected}") return "\n".join(lines) diff --git a/tests/test_openwebui_pipe.py b/tests/test_openwebui_pipe.py index b2716ab..c537e65 100644 --- a/tests/test_openwebui_pipe.py +++ b/tests/test_openwebui_pipe.py @@ -1186,3 +1186,61 @@ async def _chat_completion( assert second_content.count("Context Compiler trace") == 1 assert "downstream LLM call: no" in second_content assert "downstream LLM call: yes" not in second_content + + +def test_pipe_passthrough_injects_active_state_and_trace_reports_yes(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_pipe_passthrough_state_injection", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + forwarded_payloads: list[dict[str, object]] = [] + + async def _chat_completion( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + forwarded_payloads.append(payload) + return {"choices": [{"message": {"content": "answer"}}]} + + monkeypatch.setattr(module, "generate_chat_completion", _chat_completion) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.SHOW_CONTEXT_COMPILER_TRACE = True + chat_id = "chat-passthrough-injected-state" + + update = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "use docker"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert "State updated: Use docker." in update + assert len(forwarded_payloads) == 0 + + passthrough = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "what container runtime should i use?"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert isinstance(passthrough, dict) + assert len(forwarded_payloads) == 1 + messages = forwarded_payloads[0]["messages"] + assert isinstance(messages, list) + assert any( + isinstance(msg, dict) + and msg.get("role") == "system" + and isinstance(msg.get("content"), str) + and msg["content"].startswith("[[cc_state]]") + and "Use: docker" in msg["content"] + for msg in messages + ) + content = passthrough["choices"][0]["message"]["content"] + assert "state injected: yes" in content diff --git a/tests/test_openwebui_preprocessor_pipe.py b/tests/test_openwebui_preprocessor_pipe.py index 2449097..4cc06e8 100644 --- a/tests/test_openwebui_preprocessor_pipe.py +++ b/tests/test_openwebui_preprocessor_pipe.py @@ -2189,3 +2189,77 @@ async def _chat_completion( assert "state injected: no" in second assert downstream_calls == 0 assert heuristic_calls == ["use podman instead of kubectl"] + + +def test_preprocessor_pipe_passthrough_injects_active_state_and_trace_reports_yes( + monkeypatch, +) -> None: + module = _load_module_with_openwebui_stubs( + "owui_preproc_passthrough_state_injection", monkeypatch + ) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + monkeypatch.setattr( + module, + "preprocess_heuristic", + lambda text: ( + {"outcome": module.PREPROCESS_OUTCOME_DIRECTIVE, "directive": "use docker"} + if text.strip().lower() == "use docker" + else {"outcome": "no_directive", "directive": None} + ), + ) + monkeypatch.setattr(module, "parse_preprocessor_output", lambda value, **_kwargs: value) + + forwarded_payloads: list[dict[str, object]] = [] + + async def _chat_completion( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + forwarded_payloads.append(payload) + return {"choices": [{"message": {"content": "answer"}}]} + + monkeypatch.setattr(module, "generate_chat_completion", _chat_completion) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + pipe.valves.SHOW_CONTEXT_COMPILER_TRACE = True + chat_id = "chat-preproc-passthrough-injected-state" + + update = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "use docker"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert "State updated: Use docker." in update + assert len(forwarded_payloads) == 0 + + passthrough = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "what container runtime should i use?"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert isinstance(passthrough, dict) + assert len(forwarded_payloads) == 2 + messages = forwarded_payloads[-1]["messages"] + assert isinstance(messages, list) + assert any( + isinstance(msg, dict) + and msg.get("role") == "system" + and isinstance(msg.get("content"), str) + and msg["content"].startswith("[[cc_state]]") + and "Use: docker" in msg["content"] + for msg in messages + ) + content = passthrough["choices"][0]["message"]["content"] + assert "state injected: yes" in content From 2d700e98d06fcb3f006a62c73c18b4575f7d637e Mon Sep 17 00:00:00 2001 From: Robert Lippmann Date: Tue, 26 May 2026 03:26:38 -0400 Subject: [PATCH 2/2] fix: harden openwebui passthrough state injection --- examples/integrations/openwebui/README.md | 8 +- .../integrations/openwebui/open_webui_pipe.py | 52 +++--- .../open_webui_pipe_with_preprocessor.py | 52 +++--- pyproject.toml | 2 +- tests/test_openwebui_pipe.py | 148 ++++++++++++++++++ tests/test_openwebui_preprocessor_pipe.py | 125 +++++++++++++++ uv.lock | 2 +- 7 files changed, 325 insertions(+), 64 deletions(-) diff --git a/examples/integrations/openwebui/README.md b/examples/integrations/openwebui/README.md index 08cd7f1..63ee800 100644 --- a/examples/integrations/openwebui/README.md +++ b/examples/integrations/openwebui/README.md @@ -18,7 +18,7 @@ These examples support both sync (`0.8.x`) and async (`0.9.x`) user lookup. The minimal pipe path below is the easiest first-run flow and was runtime-validated in Docker via API flow with a real backend model. 1. Import `open_webui_pipe.py` (recommended/default) as a Function by URL. -2. Open WebUI installs `context-compiler>=0.7.0` from the function frontmatter requirements. +2. Open WebUI installs `context-compiler>=0.7.2` from the function frontmatter requirements. 3. Enable the function. 4. Set `BASE_MODEL_ID` to a valid Open WebUI model id (required). 5. Select the pipe model in chat. @@ -27,7 +27,7 @@ Open WebUI is a separate runtime and must already be installed/configured separa Open WebUI also needs at least one real backend model/provider configured (for example Ollama or OpenAI) so `BASE_MODEL_ID` resolves to an actual model. Note: The `PROVIDER` environment contract used in LiteLLM examples/demos does not apply to OpenWebUI. OpenWebUI manages providers via its own connection settings and model IDs. -Checkpoint continuation in these examples requires `context-compiler>=0.7.0`. +Checkpoint continuation in these examples requires `context-compiler>=0.7.2`. ### Model configuration @@ -64,8 +64,8 @@ If frontmatter dependency installs are disabled, offline, or unavailable: 1. Open a shell in the Open WebUI container: - `docker exec -it sh` 2. Install the package manually: - - Minimal pipe: `pip install "context-compiler>=0.7.0"` - - Preprocessor pipe: `pip install "context-compiler[experimental]>=0.7.0"` + - Minimal pipe: `pip install "context-compiler>=0.7.2"` + - Preprocessor pipe: `pip install "context-compiler[experimental]>=0.7.2"` 3. Import and enable the function in Open WebUI, then configure valves. ### Finding valid model ids diff --git a/examples/integrations/openwebui/open_webui_pipe.py b/examples/integrations/openwebui/open_webui_pipe.py index b0bd628..3435cda 100644 --- a/examples/integrations/openwebui/open_webui_pipe.py +++ b/examples/integrations/openwebui/open_webui_pipe.py @@ -3,8 +3,8 @@ author: rlippmann author_url: https://github.com/rlippmann/context-compiler funding_url: https://github.com/rlippmann/context-compiler -version: 0.9.0 -requirements: context-compiler>=0.7.0 +version: 0.9.1 +requirements: context-compiler>=0.7.2 Minimal Open WebUI Pipe integration for Context Compiler. @@ -235,6 +235,25 @@ def _strip_trace_blocks_from_messages(messages: list[dict[str, Any]]) -> list[di return cleaned +def _build_forward_messages( + raw_messages: object, + *, + state: State | None = None, +) -> list[dict[str, Any]]: + """Build forwarded messages with trace stripping and optional state injection.""" + messages = ( + _strip_trace_blocks_from_messages([msg for msg in raw_messages if isinstance(msg, dict)]) + if isinstance(raw_messages, list) + else [] + ) + if state is not None and _has_non_empty_authoritative_state(state): + return _replace_compiler_system_message( + messages, + _render_compiler_state_block(state), + ) + return messages + + def _strip_existing_trace_from_chunk(chunk: object) -> object: if isinstance(chunk, str): return _strip_trace_block_from_text(chunk) @@ -487,21 +506,7 @@ async def _forward_passthrough( """Forward with model override and optional compiler-owned state injection.""" payload = {**body} payload["model"] = self.valves.BASE_MODEL_ID - raw_messages = body.get("messages") - messages = ( - _strip_trace_blocks_from_messages( - [msg for msg in raw_messages if isinstance(msg, dict)] - ) - if isinstance(raw_messages, list) - else [] - ) - if state is not None and _has_non_empty_authoritative_state(state): - payload["messages"] = _replace_compiler_system_message( - messages, - _render_compiler_state_block(state), - ) - elif isinstance(raw_messages, list): - payload["messages"] = messages + payload["messages"] = _build_forward_messages(body.get("messages"), state=state) user = Users.get_user_by_id(user_payload["id"]) if inspect.isawaitable(user): user = await user @@ -532,18 +537,7 @@ async def _forward_update( payload = {**body} payload["model"] = self.valves.BASE_MODEL_ID - raw_messages = body.get("messages") - messages = ( - _strip_trace_blocks_from_messages( - [msg for msg in raw_messages if isinstance(msg, dict)] - ) - if isinstance(raw_messages, list) - else [] - ) - payload["messages"] = _replace_compiler_system_message( - messages, - _render_compiler_state_block(state), - ) + payload["messages"] = _build_forward_messages(body.get("messages"), state=state) user = Users.get_user_by_id(user_payload["id"]) if inspect.isawaitable(user): diff --git a/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py b/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py index 971584d..a506d5e 100644 --- a/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py +++ b/examples/integrations/openwebui/open_webui_pipe_with_preprocessor.py @@ -3,8 +3,8 @@ author: rlippmann author_url: https://github.com/rlippmann/context-compiler funding_url: https://github.com/rlippmann/context-compiler -version: 0.9.0 -requirements: context-compiler[experimental]>=0.7.0 +version: 0.9.1 +requirements: context-compiler[experimental]>=0.7.2 Open WebUI integration with Context Compiler preprocessor. @@ -240,6 +240,25 @@ def _strip_trace_blocks_from_messages(messages: list[dict[str, Any]]) -> list[di return cleaned +def _build_forward_messages( + raw_messages: object, + *, + state: State | None = None, +) -> list[dict[str, Any]]: + """Build forwarded messages with trace stripping and optional state injection.""" + messages = ( + _strip_trace_blocks_from_messages([msg for msg in raw_messages if isinstance(msg, dict)]) + if isinstance(raw_messages, list) + else [] + ) + if state is not None and _has_non_empty_authoritative_state(state): + return _replace_compiler_system_message( + messages, + _render_compiler_state_block(state), + ) + return messages + + def _strip_existing_trace_from_chunk(chunk: object) -> object: if isinstance(chunk, str): return _strip_trace_block_from_text(chunk) @@ -713,21 +732,7 @@ async def _forward_passthrough( ) payload = {**body} payload["model"] = base_model_id - raw_messages = body.get("messages") - messages = ( - _strip_trace_blocks_from_messages( - [msg for msg in raw_messages if isinstance(msg, dict)] - ) - if isinstance(raw_messages, list) - else [] - ) - if state is not None and _has_non_empty_authoritative_state(state): - payload["messages"] = _replace_compiler_system_message( - messages, - _render_compiler_state_block(state), - ) - elif isinstance(raw_messages, list): - payload["messages"] = messages + payload["messages"] = _build_forward_messages(body.get("messages"), state=state) user = Users.get_user_by_id(user_payload["id"]) if inspect.isawaitable(user): user = await user @@ -765,18 +770,7 @@ async def _forward_update( payload = {**body} payload["model"] = base_model_id - raw_messages = body.get("messages") - messages = ( - _strip_trace_blocks_from_messages( - [msg for msg in raw_messages if isinstance(msg, dict)] - ) - if isinstance(raw_messages, list) - else [] - ) - payload["messages"] = _replace_compiler_system_message( - messages, - _render_compiler_state_block(state), - ) + payload["messages"] = _build_forward_messages(body.get("messages"), state=state) user = Users.get_user_by_id(user_payload["id"]) if inspect.isawaitable(user): diff --git a/pyproject.toml b/pyproject.toml index 22f9363..5910ed0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "context-compiler" -version = "0.7.1" +version = "0.7.2" description = "Deterministic conversational state engine for LLM applications." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_openwebui_pipe.py b/tests/test_openwebui_pipe.py index c537e65..6554607 100644 --- a/tests/test_openwebui_pipe.py +++ b/tests/test_openwebui_pipe.py @@ -615,6 +615,56 @@ async def _track_downstream( assert downstream_calls == 0 +def test_pipe_incomplete_replacement_clarify_has_no_model_call(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs( + "owui_pipe_incomplete_replacement_clarify", monkeypatch + ) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + downstream_calls = 0 + + async def _track_downstream( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + nonlocal downstream_calls + downstream_calls += 1 + raise AssertionError(f"downstream model should not be called: {payload.get('model')}") + + module.generate_chat_completion = _track_downstream + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + chat_id = "chat-incomplete-replacement-clarify" + + result = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": "use docker instead of"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert result == ( + "Replacement requires both new and old items.\n" + "Use 'use instead of ' with non-empty values." + ) + assert downstream_calls == 0 + + state_view = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "show state"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + assert state_view == "Premise: none\nUse: none\nProhibit: none\nPending clarification: no" + + @pytest.mark.parametrize( ("confirmation", "expected_policies"), [ @@ -1244,3 +1294,101 @@ async def _chat_completion( ) content = passthrough["choices"][0]["message"]["content"] assert "state injected: yes" in content + + +def test_pipe_empty_state_passthrough_does_not_inject_and_trace_reports_no(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_pipe_empty_state_passthrough", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + forwarded_payloads: list[dict[str, object]] = [] + + async def _chat_completion( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + forwarded_payloads.append(payload) + return {"choices": [{"message": {"content": "answer"}}]} + + monkeypatch.setattr(module, "generate_chat_completion", _chat_completion) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.SHOW_CONTEXT_COMPILER_TRACE = True + + passthrough = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "hello"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__="chat-empty-state-passthrough", + ) + ) + assert isinstance(passthrough, dict) + assert len(forwarded_payloads) == 1 + messages = forwarded_payloads[0]["messages"] + assert isinstance(messages, list) + assert not any( + isinstance(msg, dict) + and msg.get("role") == "system" + and isinstance(msg.get("content"), str) + and msg["content"].startswith("[[cc_state]]") + for msg in messages + ) + content = passthrough["choices"][0]["message"]["content"] + assert "downstream LLM call: yes" in content + assert "state injected: no" in content + + +def test_pipe_repeated_passthrough_does_not_duplicate_compiler_state_prompt(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_pipe_repeated_passthrough_no_dup", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + + forwarded_payloads: list[dict[str, object]] = [] + + async def _chat_completion( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + forwarded_payloads.append(payload) + return {"choices": [{"message": {"content": "answer"}}]} + + monkeypatch.setattr(module, "generate_chat_completion", _chat_completion) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + chat_id = "chat-repeated-passthrough-no-dup" + + _ = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "use docker"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + for idx in range(2): + _ = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": f"question {idx}"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + + assert len(forwarded_payloads) == 2 + for payload in forwarded_payloads: + messages = payload.get("messages") + assert isinstance(messages, list) + cc_messages = [ + msg + for msg in messages + if isinstance(msg, dict) + and msg.get("role") == "system" + and isinstance(msg.get("content"), str) + and msg["content"].startswith("[[cc_state]]") + ] + assert len(cc_messages) == 1 diff --git a/tests/test_openwebui_preprocessor_pipe.py b/tests/test_openwebui_preprocessor_pipe.py index 4cc06e8..6ace403 100644 --- a/tests/test_openwebui_preprocessor_pipe.py +++ b/tests/test_openwebui_preprocessor_pipe.py @@ -2263,3 +2263,128 @@ async def _chat_completion( ) content = passthrough["choices"][0]["message"]["content"] assert "state injected: yes" in content + + +def test_preprocessor_pipe_empty_state_passthrough_does_not_inject_and_trace_reports_no( + monkeypatch, +) -> None: + module = _load_module_with_openwebui_stubs("owui_preproc_empty_state_passthrough", monkeypatch) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + monkeypatch.setattr(module, "preprocess_heuristic", lambda _text: {"outcome": "no_directive"}) + monkeypatch.setattr(module, "parse_preprocessor_output", lambda _value, **_kwargs: None) + + forwarded_payloads: list[dict[str, object]] = [] + + async def _chat_completion( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + forwarded_payloads.append(payload) + return {"choices": [{"message": {"content": "answer"}}]} + + monkeypatch.setattr(module, "generate_chat_completion", _chat_completion) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + pipe.valves.SHOW_CONTEXT_COMPILER_TRACE = True + + passthrough = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "hello"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__="chat-preproc-empty-state-passthrough", + ) + ) + assert isinstance(passthrough, dict) + assert len(forwarded_payloads) == 2 + messages = forwarded_payloads[-1]["messages"] + assert isinstance(messages, list) + assert not any( + isinstance(msg, dict) + and msg.get("role") == "system" + and isinstance(msg.get("content"), str) + and msg["content"].startswith("[[cc_state]]") + for msg in messages + ) + content = passthrough["choices"][0]["message"]["content"] + assert "downstream LLM call: yes" in content + assert "state injected: no" in content + + +def test_preprocessor_pipe_repeated_passthrough_does_not_duplicate_compiler_state_prompt( + monkeypatch, +) -> None: + module = _load_module_with_openwebui_stubs( + "owui_preproc_repeated_passthrough_no_dup", monkeypatch + ) + module._ENGINES_BY_CHAT_KEY.clear() + module._CHECKPOINTS_BY_CHAT_KEY.clear() + monkeypatch.setattr( + module, + "preprocess_heuristic", + lambda text: ( + {"outcome": module.PREPROCESS_OUTCOME_DIRECTIVE, "directive": "use docker"} + if text.strip().lower() == "use docker" + else {"outcome": "no_directive", "directive": None} + ), + ) + monkeypatch.setattr(module, "parse_preprocessor_output", lambda value, **_kwargs: value) + + forwarded_payloads: list[dict[str, object]] = [] + + async def _chat_completion( + _: object, payload: dict[str, object], __: object + ) -> dict[str, object]: + forwarded_payloads.append(payload) + return {"choices": [{"message": {"content": "answer"}}]} + + monkeypatch.setattr(module, "generate_chat_completion", _chat_completion) + + pipe = module.Pipe() + pipe.valves.BASE_MODEL_ID = "base-model" + pipe.valves.PREPROCESSOR_MODEL_ID = "prep-model" + chat_id = "chat-preproc-repeated-passthrough-no-dup" + + _ = asyncio.run( + pipe.pipe( + {"model": "pipe-model", "messages": [{"role": "user", "content": "use docker"}]}, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + for idx in range(2): + _ = asyncio.run( + pipe.pipe( + { + "model": "pipe-model", + "messages": [{"role": "user", "content": f"question {idx}"}], + }, + __user__={"id": "u1"}, + __request__=object(), + __chat_id__=chat_id, + ) + ) + + assert len(forwarded_payloads) == 4 + passthrough_payloads = [forwarded_payloads[1], forwarded_payloads[3]] + for payload in passthrough_payloads: + messages = payload.get("messages") + assert isinstance(messages, list) + cc_messages = [ + msg + for msg in messages + if isinstance(msg, dict) + and msg.get("role") == "system" + and isinstance(msg.get("content"), str) + and msg["content"].startswith("[[cc_state]]") + ] + assert len(cc_messages) == 1 + + +def test_preprocessor_pipe_frontmatter_title_and_public_symbol_stability(monkeypatch) -> None: + module = _load_module_with_openwebui_stubs("owui_preproc_frontmatter_title", monkeypatch) + assert "title: Context Compiler Pipe (Preprocessor)" in (module.__doc__ or "") + assert hasattr(module, "Pipe") diff --git a/uv.lock b/uv.lock index 154f740..f433f94 100644 --- a/uv.lock +++ b/uv.lock @@ -468,7 +468,7 @@ wheels = [ [[package]] name = "context-compiler" -version = "0.7.1" +version = "0.7.2" source = { editable = "." } [package.optional-dependencies]