Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/conductor/engine/dialog_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 7 additions & 1 deletion src/conductor/engine/validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
16 changes: 13 additions & 3 deletions src/conductor/engine/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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]

Expand Down
12 changes: 9 additions & 3 deletions src/conductor/gates/dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
28 changes: 28 additions & 0 deletions tests/test_engine/test_dialog_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
20 changes: 20 additions & 0 deletions tests/test_engine/test_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
28 changes: 28 additions & 0 deletions tests/test_engine/test_workflow_interrupt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
102 changes: 102 additions & 0 deletions tests/test_gates/test_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)