Skip to content
Open
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
8 changes: 6 additions & 2 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,27 @@
Test configuration for agent tests.
"""

import sys
from pathlib import Path

import pytest

# Add the agents path
agents_path = Path(__file__).parent.parent.parent / "backend" / "v4" / "magentic_agents"
sys.path.insert(0, str(agents_path))


@pytest.fixture
def agent_env_vars():
"""Common environment variables for agent testing."""
return {
"BING_CONNECTION_NAME": "test_bing_connection",
"MCP_SERVER_ENDPOINT": "http://test-mcp-server",
"MCP_SERVER_NAME": "test_mcp_server",
"MCP_SERVER_NAME": "test_mcp_server",
"MCP_SERVER_DESCRIPTION": "Test MCP server",
"TENANT_ID": "test_tenant_id",
"CLIENT_ID": "test_client_id",
"AZURE_OPENAI_ENDPOINT": "https://test.openai.azure.com/",
"AZURE_OPENAI_API_KEY": "test_key",
"AZURE_OPENAI_DEPLOYMENT_NAME": "test_deployment"
}
}
2 changes: 1 addition & 1 deletion src/backend/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -1466,4 +1466,4 @@ async def get_generated_image(blob_name: str):
return Response(content=data, media_type="image/png")
except Exception as exc:
logging.error(f"Error retrieving image '{blob_name}': {exc}")
raise HTTPException(status_code=404, detail="Image not found")
raise HTTPException(status_code=404, detail="Image not found")
4 changes: 4 additions & 0 deletions src/backend/callbacks/response_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
AgentToolCall, AgentToolMessage,
WebsocketMessageType)
from orchestration.connection_config import connection_config
from common.utils.markdown_utils import normalize_markdown_tables

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -73,6 +74,9 @@ def agent_response_callback(

text = clean_citations(text or "")

# Repair collapsed markdown tables before rendering (Bug 47810).
text = normalize_markdown_tables(text)

if not user_id:
logger.debug("No user_id provided; skipping websocket send for final message.")
return
Expand Down
70 changes: 70 additions & 0 deletions src/backend/common/utils/markdown_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Shared GFM table repair utilities (Bug 47810)."""

import re
from typing import List, Optional

# Matches a GFM delimiter cell: "---", ":--", "--:", ":-:".
_TABLE_DELIM_CELL_RE = re.compile(r"^\s*:?-+:?\s*$")


def reflow_collapsed_table_line(line: str) -> Optional[List[str]]:
"""Rebuild a GFM table flattened onto one line; return None if not collapsed."""
if line.count("|") < 4 or "-" not in line:
return None

first = line.index("|")
prefix = line[:first].rstrip()
raw = line[first:].rstrip()

tokens = raw.split("|")
if tokens and tokens[0].strip() == "":
tokens = tokens[1:]
if tokens and tokens[-1].strip() == "":
tokens = tokens[:-1]
if not tokens:
return None

# Whitespace-only tokens mark the boundary between flattened rows.
rows: List[List[str]] = []
current: List[str] = []
for tok in tokens:
if tok.strip() == "":
if current:
rows.append(current)
current = []
else:
current.append(tok)
if current:
rows.append(current)

# Require a header plus a delimiter row; leaves well-formed tables untouched.
if len(rows) < 2 or not all(_TABLE_DELIM_CELL_RE.match(c) for c in rows[1]):
return None

n = len(rows[1])
if n == 0 or len(rows[0]) != n:
return None
Comment thread
Ayaz-Microsoft marked this conversation as resolved.

rendered = ["| " + " | ".join(cell.strip() for cell in row) + " |" for row in rows]

result: List[str] = []
if prefix:
result.append(prefix)
result.append("") # Blank line so GFM starts a fresh table block.
result.extend(rendered)
return result


def normalize_markdown_tables(text: str) -> str:
"""Repair collapsed GFM tables; non-table text is returned unchanged."""
if not text or "|" not in text or "-" not in text:
return text
Comment thread
Ayaz-Microsoft marked this conversation as resolved.

out: List[str] = []
for line in text.split("\n"):
reflowed = reflow_collapsed_table_line(line)
if reflowed is None:
out.append(line)
else:
out.extend(reflowed)
return "\n".join(out)
36 changes: 18 additions & 18 deletions src/backend/orchestration/orchestration_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from common.config.app_config import config
from common.database.database_base import DatabaseBase
from common.models.messages import TeamConfiguration
from common.utils.markdown_utils import \
normalize_markdown_tables as _normalize_markdown_tables
from models.messages import AgentMessageStreaming, WebsocketMessageType
from orchestration.connection_config import (connection_config,
orchestration_config)
Expand All @@ -35,18 +37,18 @@
apply_tool_history_leak_patch()

_BARE_IMAGE_URL_RE = re.compile(
r"(?<![\(\]])"
r"(?<!\]\()"
r"("
# Absolute image URL (any host, or a backend /api/v4/images path)
r"https?://[^\s)]+?(?:/api/v4/images/[^\s)]+?|[^\s)]+?\.(?:png|jpe?g|gif|webp))"
# Bare relative backend image path (emitted by the MCP/backend image tools).
# The (?<![^\s]) guard requires the path to start at whitespace/string-start so
# it never matches the same substring inside an absolute URL.
r"|(?<![^\s])/api/v4/images/[^\s)]+?\.(?:png|jpe?g|gif|webp)"
r")"
r"(?=[\s)\]]|$)",
re.IGNORECASE,
r"(?<![\(\]])"
r"(?<!\]\()"
r"("
# Absolute image URL (any host, or a backend /api/v4/images path)
r"https?://[^\s)]+?(?:/api/v4/images/[^\s)]+?|[^\s)]+?\.(?:png|jpe?g|gif|webp))"
# Bare relative backend image path (emitted by the MCP/backend image tools).
# The (?<![^\s]) guard requires the path to start at whitespace/string-start so
# it never matches the same substring inside an absolute URL.
r"|(?<![^\s])/api/v4/images/[^\s)]+?\.(?:png|jpe?g|gif|webp)"
r")"
r"(?=[\s)\]]|$)",
re.IGNORECASE,
)


Expand Down Expand Up @@ -218,25 +220,20 @@ async def get_current_or_new_orchestration(
current is not None and current_team_id != team_config.team_id
)


cls.logger.info(
"get_current_or_new_orchestration: user='%s' selected_team='%s' "
"cached_team='%s' team_switched=%s team_changed=%s current_is_none=%s",
user_id, team_config.team_id, current_team_id,
team_switched, team_changed, current is None,
)


# Full rebuild: no workflow exists, team explicitly switched, or the
# cached workflow belongs to a different team than the selected one.
needs_full_rebuild = current is None or team_switched or team_changed


# Lightweight reset: workflow finished but agents are still valid for the
# same team (a team change always routes to full rebuild above so we
# never reuse the previous team's agents here).


needs_workflow_reset = not needs_full_rebuild and workflow_terminated

if needs_full_rebuild:
Expand Down Expand Up @@ -442,6 +439,9 @@ async def run_orchestration(self, user_id: str, input_task) -> None:
# accumulated orchestrator streaming chunks.
final_text = final_output_ref[0] or "".join(orchestrator_chunks)

# Repair collapsed markdown tables before rendering (Bug 47810).
final_text = _normalize_markdown_tables(final_text)

final_text = _embed_bare_image_urls(final_text)

# Issue 1 diagnostic: confirm the final answer carries a renderable image
Expand Down Expand Up @@ -852,4 +852,4 @@ async def _process_event_stream(
if tool_approvals:
result["tool_approvals"] = tool_approvals
return result
return None
return None
79 changes: 79 additions & 0 deletions src/tests/backend/orchestration/test_orchestration_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,19 @@ def _make_workflow_mock(run_return=None, executors=None):
sys.modules['common.config.app_config'] = Mock(config=mock_config)
sys.modules['common.models'] = Mock()

# Register the real markdown_utils so the orchestrator uses genuine table logic, not a Mock (Bug 47810).
import importlib.util as _ilu # noqa: E402

_md_path = os.path.join(
os.path.dirname(__file__), "..", "..", "..", "backend",
"common", "utils", "markdown_utils.py",
)
_md_spec = _ilu.spec_from_file_location("common.utils.markdown_utils", _md_path)
_markdown_utils = _ilu.module_from_spec(_md_spec)
_md_spec.loader.exec_module(_markdown_utils)
sys.modules['common.utils'] = Mock()
Comment thread
Ayaz-Microsoft marked this conversation as resolved.
sys.modules['common.utils.markdown_utils'] = _markdown_utils


class MockTeamConfiguration:
def __init__(self, name="TestTeam", deployment_name="test_deployment"):
Expand Down Expand Up @@ -991,3 +1004,69 @@ def test_given_new_instance_when_init_then_logger_is_set(self):
manager = OrchestrationManager()

assert isinstance(manager.logger, logging.Logger)


# _normalize_markdown_tables (Bug 47810)
from backend.orchestration.orchestration_manager import ( # noqa: E402
_normalize_markdown_tables,
)
from common.utils.markdown_utils import ( # noqa: E402
reflow_collapsed_table_line as _reflow_collapsed_table_line,
)


class TestNormalizeMarkdownTables:
"""Test markdown table re-flow for collapsed orchestrator output (Bug 47810)."""

def test_given_collapsed_table_when_normalized_then_rows_split_to_lines(self):
collapsed = (
"| Risk Type | Description | Rating | "
"|-------|-------|-------| "
"| Delivery | Undefined timeline | Medium | "
"| Financial | Fixed budget | High |"
)

result = _normalize_markdown_tables(collapsed)

lines = [ln for ln in result.split("\n") if ln.strip()]
assert lines == [
"| Risk Type | Description | Rating |",
"| ------- | ------- | ------- |",
"| Delivery | Undefined timeline | Medium |",
"| Financial | Fixed budget | High |",
]

def test_given_collapsed_table_with_prefix_then_prefix_kept_on_own_line(self):
collapsed = (
"Risk Analysis | A | B | |---|---| | 1 | 2 |"
)

result = _normalize_markdown_tables(collapsed)

# Prefix prose separated from the table by a blank line for GFM.
assert result.startswith("Risk Analysis\n\n| A | B |")

def test_given_wellformed_table_when_normalized_then_unchanged(self):
good = "| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |"

assert _normalize_markdown_tables(good) == good

def test_given_plain_text_when_normalized_then_unchanged(self):
text = "Just some text with a - dash and | a pipe."

assert _normalize_markdown_tables(text) == text

def test_given_colon_aligned_delimiter_when_normalized_then_alignment_kept(self):
collapsed = "| A | B | C | |:--|:-:|--:| | 1 | 2 | 3 |"

result = _normalize_markdown_tables(collapsed)

assert "| :-- | :-: | --: |" in result

def test_given_empty_or_none_when_normalized_then_returns_input(self):
assert _normalize_markdown_tables("") == ""
assert _normalize_markdown_tables(None) is None

def test_given_non_table_pipe_line_when_reflowed_then_returns_none(self):
assert _reflow_collapsed_table_line("a | b | c") is None

Loading