diff --git a/src/conductor/engine/dialog_evaluator.py b/src/conductor/engine/dialog_evaluator.py index f7a0d1fa..45699d93 100644 --- a/src/conductor/engine/dialog_evaluator.py +++ b/src/conductor/engine/dialog_evaluator.py @@ -123,7 +123,10 @@ async def _run_evaluator( DialogEvaluation with trigger decision and opening question. """ try: - output_str = json.dumps(output, indent=2, default=str) + # ``ensure_ascii=False`` so the 4000-char budget below is measured + # in real characters for every language — same truncation-fairness + # fix as the output validator (issue #356). + output_str = json.dumps(output, indent=2, default=str, ensure_ascii=False) except (TypeError, ValueError): output_str = str(output) diff --git a/src/conductor/engine/validator.py b/src/conductor/engine/validator.py index 44b63044..a642df72 100644 --- a/src/conductor/engine/validator.py +++ b/src/conductor/engine/validator.py @@ -164,7 +164,13 @@ async def validate( return ValidationOutcome(passed=True) try: - output_str = json.dumps(primary_output, indent=2, default=str) + # ``ensure_ascii=False`` keeps non-ASCII text literal so the fixed + # ``_OUTPUT_LIMIT`` budget is measured in real characters for every + # language — with the default ``ensure_ascii=True`` each Cyrillic / + # CJK code point inflates to 6 (and emoji to 12) ``\uXXXX`` chars + # before truncation, shrinking the effective budget ~6x and letting + # the cut land inside an escape sequence (issue #356). + output_str = json.dumps(primary_output, indent=2, default=str, ensure_ascii=False) except (TypeError, ValueError): output_str = str(primary_output) diff --git a/src/conductor/engine/workflow.py b/src/conductor/engine/workflow.py index 4af15aad..e90e9d45 100644 --- a/src/conductor/engine/workflow.py +++ b/src/conductor/engine/workflow.py @@ -2432,7 +2432,9 @@ async def _check_interrupt(self, current_agent_name: str) -> InterruptResult | N last_output_preview: str | None = None if last_output is not None: try: - preview = json.dumps(last_output, indent=2, default=str) + # ``ensure_ascii=False`` so the preview shows real non-ASCII + # text instead of \uXXXX escapes (issue #356). + preview = json.dumps(last_output, indent=2, default=str, ensure_ascii=False) last_output_preview = preview[:500] except (TypeError, ValueError): last_output_preview = str(last_output)[:500] @@ -2502,7 +2504,11 @@ async def _handle_web_pause(self, agent_name: str, partial_output: AgentOutput) return False try: - preview = json.dumps(partial_output.content, indent=2, default=str)[:500] + # ``ensure_ascii=False`` so the preview shows real non-ASCII + # text instead of \uXXXX escapes (issue #356). + preview = json.dumps(partial_output.content, indent=2, default=str, ensure_ascii=False)[ + :500 + ] except (TypeError, ValueError): preview = str(partial_output.content)[:500] @@ -2627,7 +2633,11 @@ async def _handle_partial_output( # Build preview from partial output try: - preview = json.dumps(partial_output.content, indent=2, default=str)[:500] + # ``ensure_ascii=False`` so the preview shows real non-ASCII + # text instead of \uXXXX escapes (issue #356). + preview = json.dumps(partial_output.content, indent=2, default=str, ensure_ascii=False)[ + :500 + ] except (TypeError, ValueError): preview = str(partial_output.content)[:500] diff --git a/src/conductor/gates/dialog.py b/src/conductor/gates/dialog.py index 66f0d3ac..84847cf9 100644 --- a/src/conductor/gates/dialog.py +++ b/src/conductor/gates/dialog.py @@ -219,7 +219,9 @@ async def handle_dialog( # Build the system prompt with full agent output context try: - output_str = json.dumps(agent_output, indent=2, default=str) + # ``ensure_ascii=False`` so the dialog-mode LLM sees non-ASCII + # output literally instead of as \uXXXX escapes (issue #356). + output_str = json.dumps(agent_output, indent=2, default=str, ensure_ascii=False) except (TypeError, ValueError): output_str = str(agent_output) @@ -385,7 +387,9 @@ async def _web_handle_dialog( # Build the system prompt with full agent output context try: - output_str = json.dumps(agent_output, indent=2, default=str) + # ``ensure_ascii=False`` so the dialog-mode LLM sees non-ASCII + # output literally instead of as \uXXXX escapes (issue #356). + output_str = json.dumps(agent_output, indent=2, default=str, ensure_ascii=False) except (TypeError, ValueError): output_str = str(agent_output) @@ -609,7 +613,9 @@ def _display_dialog_start( # Show agent output with full context try: - output_str = json.dumps(agent_output, indent=2, default=str) + # ``ensure_ascii=False`` so the console panel shows real non-ASCII + # text instead of \uXXXX escapes (issue #356). + output_str = json.dumps(agent_output, indent=2, default=str, ensure_ascii=False) except (TypeError, ValueError): output_str = str(agent_output) diff --git a/tests/test_engine/test_dialog_evaluator.py b/tests/test_engine/test_dialog_evaluator.py index 1cd86da8..2aafe89a 100644 --- a/tests/test_engine/test_dialog_evaluator.py +++ b/tests/test_engine/test_dialog_evaluator.py @@ -115,6 +115,34 @@ async def test_evaluate_truncates_large_output(self) -> None: user_message = call_args.kwargs["user_message"] assert "…[truncated]" in user_message + @pytest.mark.asyncio + async def test_evaluate_serializes_non_ascii_unescaped(self) -> None: + """Non-ASCII output reaches the dialog evaluator unescaped.""" + # Requirement: the 4000-char evaluator budget must be measured in real + # characters for every language — non-ASCII output reaches the + # evaluator prompt unescaped and is not truncated when it fits the + # budget (issue #356). + evaluator = DialogEvaluator() + agent = AgentDef( + name="test", + prompt="test", + dialog=DialogConfig(trigger_prompt="Enter dialog if uncertain"), + ) + provider = MagicMock() + provider.execute_dialog_turn = AsyncMock( + return_value='{"trigger": false, "reason": "clear"}' + ) + + # 1000 CJK chars: ~1 KB unescaped (fits the 4000 budget), + # ~6 KB escaped (would be truncated mid-escape). + cjk_output = {"text": "你" * 1000} + await evaluator.evaluate(agent, cjk_output, provider) + + user_message = provider.execute_dialog_turn.call_args.kwargs["user_message"] + assert "你" * 1000 in user_message + assert "\\u4f60" not in user_message + assert "…[truncated]" not in user_message + class TestDialogEvaluatorEvaluate: """Tests for the full evaluate() method.""" diff --git a/tests/test_engine/test_validator.py b/tests/test_engine/test_validator.py index 4702a9bd..c74a700f 100644 --- a/tests/test_engine/test_validator.py +++ b/tests/test_engine/test_validator.py @@ -232,6 +232,26 @@ async def test_truncates_large_output(self) -> None: rendered = provider.execute.call_args.kwargs["rendered_prompt"] assert "…[truncated]" in rendered + @pytest.mark.asyncio + async def test_non_ascii_output_serialized_unescaped(self) -> None: + # Requirement: the fixed _OUTPUT_LIMIT budget must be measured in real + # characters for every language — non-ASCII output reaches the + # validator prompt unescaped and is not truncated when it fits the + # budget (issue #356). + agent = _agent() + provider = MagicMock() + provider.execute = AsyncMock(return_value=_agent_output({"passed": True, "issues": []})) + + # 2000 CJK chars: ~2 KB unescaped (fits the 8000 budget), + # ~12 KB escaped (would be truncated). + cjk_output = {"text": "你" * 2000} + await OutputValidator().validate(agent, "p", cjk_output, provider) + + rendered = provider.execute.call_args.kwargs["rendered_prompt"] + assert "你" * 2000 in rendered + assert "\\u4f60" not in rendered + assert "…[truncated]" not in rendered + @pytest.mark.asyncio async def test_validator_runs_without_tools(self) -> None: agent = _agent() diff --git a/tests/test_engine/test_workflow_interrupt.py b/tests/test_engine/test_workflow_interrupt.py index 2b5c1d37..97a72503 100644 --- a/tests/test_engine/test_workflow_interrupt.py +++ b/tests/test_engine/test_workflow_interrupt.py @@ -695,6 +695,34 @@ async def test_no_output_preview_when_context_empty( ) assert preview is None + @pytest.mark.asyncio + async def test_output_preview_serializes_non_ascii_unescaped( + self, two_agent_config: WorkflowConfig + ) -> None: + # Requirement: the interrupt preview shown to the user must render + # non-ASCII agent output as real text, not \uXXXX escapes (issue #356, + # PR #359 review). + event = asyncio.Event() + event.set() + provider = CopilotProvider(mock_handler=lambda a, p, c: {}) + engine = WorkflowEngine(two_agent_config, provider, interrupt_event=event) + + engine.context.store("planner", {"plan": "план 你好"}) + + cancel_result = InterruptResult(action=InterruptAction.CANCEL) + with patch.object( + engine._interrupt_handler, + "handle_interrupt", + return_value=cancel_result, + ) as mock_handle: + await engine._check_interrupt("executor") + + call_kwargs = mock_handle.call_args + preview = call_kwargs[1].get("last_output_preview") or call_kwargs[0][2] + assert "план 你好" in preview + assert "\\u4f60" not in preview + assert "\\u043f" not in preview + class TestGetTopLevelAgentNames: """Tests for _get_top_level_agent_names helper.""" diff --git a/tests/test_gates/test_dialog.py b/tests/test_gates/test_dialog.py index b9bea730..4b89e8c6 100644 --- a/tests/test_gates/test_dialog.py +++ b/tests/test_gates/test_dialog.py @@ -626,3 +626,105 @@ async def test_web_ready_marker_approval_yes(self) -> None: assert result.agent_proposed_continue is True assert not result.user_dismissed assert not result.user_declined + + +class TestDialogNonAsciiOutput: + """Non-ASCII agent output must reach the dialog LLM and the console unescaped.""" + + @pytest.mark.asyncio + async def test_cli_system_prompt_serializes_non_ascii_unescaped(self) -> None: + # Requirement: the dialog-mode system prompt embeds the agent output + # literally for every language — Cyrillic/CJK output must not reach the + # model as \uXXXX escape sequences (issue #356, PR #359 review). + handler = DialogHandler(console=MagicMock()) + agent = AgentDef( + name="test", + prompt="test", + dialog=DialogConfig(trigger_prompt="test"), + ) + provider = MagicMock() + provider.execute_dialog_turn = AsyncMock(return_value="answer") + + with ( + patch.object(handler, "_ask_engagement", new_callable=AsyncMock, return_value="engage"), + patch.object( + handler, + "_get_user_input", + new_callable=AsyncMock, + side_effect=["привет", "done"], + ), + ): + await handler.handle_dialog( + agent=agent, + agent_output={"result": "你好 мир"}, + opening_question="?", + provider=provider, + ) + + system_prompt = provider.execute_dialog_turn.call_args.kwargs["system_prompt"] + assert "你好 мир" in system_prompt + assert "\\u4f60" not in system_prompt + assert "\\u043f" not in system_prompt + + @pytest.mark.asyncio + async def test_web_system_prompt_serializes_non_ascii_unescaped(self) -> None: + # Requirement: the web-mode dialog builds the same system prompt — the + # non-ASCII output must be embedded literally there too (issue #356, + # PR #359 review). + dashboard = MagicMock() + dashboard.wait_for_dialog_message = AsyncMock( + side_effect=[ + {"type": "dialog_message", "agent_name": "test", "content": "расскажи"}, + {"type": "dialog_message", "agent_name": "test", "content": "done"}, + ] + ) + handler = DialogHandler(console=MagicMock(), web_dashboard=dashboard) + agent = AgentDef( + name="test", + prompt="test", + dialog=DialogConfig(trigger_prompt="test"), + ) + provider = MagicMock() + provider.execute_dialog_turn = AsyncMock(return_value="answer") + + await handler.handle_dialog( + agent=agent, + agent_output={"result": "你好 мир"}, + opening_question="?", + provider=provider, + ) + + provider.execute_dialog_turn.assert_called_once() + system_prompt = provider.execute_dialog_turn.call_args.kwargs["system_prompt"] + assert "你好 мир" in system_prompt + assert "\\u4f60" not in system_prompt + assert "\\u043f" not in system_prompt + + def test_console_panel_renders_non_ascii_unescaped(self) -> None: + # Requirement: the "Agent Output" console panel a human reads during a + # CLI dialog session must show real non-ASCII text, not \uXXXX escapes + # (issue #356, PR #359 review). + console = MagicMock() + handler = DialogHandler(console=console) + agent = AgentDef( + name="test", + prompt="test", + dialog=DialogConfig(trigger_prompt="test"), + ) + + handler._display_dialog_start(agent, {"result": "你好 мир"}, "?", base_dir=None) + + # Panels render lazily, so inspect the RichMarkdown renderable inside + # the "Agent Output" panel rather than str() of the Panel itself. + from rich.markdown import Markdown as RichMarkdown + from rich.panel import Panel + + panels = [call.args[0] for call in console.print.call_args_list if call.args] + markdown_bodies = [ + p.renderable.markup + for p in panels + if isinstance(p, Panel) and isinstance(p.renderable, RichMarkdown) + ] + assert any("你好 мир" in body for body in markdown_bodies) + assert all("\\u4f60" not in body for body in markdown_bodies) + assert all("\\u043f" not in body for body in markdown_bodies)