Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@

from ._errors import DeclarativeWorkflowError
from ._powerfx_functions import (
_extract_message_text, # pyright: ignore[reportPrivateUsage]
_iter_powerfx_expression_indices, # pyright: ignore[reportPrivateUsage]
whole_call_args,
)
Expand Down Expand Up @@ -791,34 +792,11 @@ def _eval_and_replace_message_text(self, inner_expr: str) -> str:
messages: Any = self.eval(f"={inner_expr}")
if isinstance(messages, list) and messages:
message_list = cast(list[Any], messages)
last_msg: Any = message_list[-1]
if isinstance(last_msg, dict):
last_msg_dict = cast(dict[str, Any], last_msg)
# Try "text" key first (simple dict format)
if "text" in last_msg_dict:
return str(last_msg_dict["text"])
# Try extracting from "contents" (Message dict format)
# Message.text concatenates text from all TextContent items
contents_obj = last_msg_dict.get("contents", [])
if isinstance(contents_obj, list):
contents = cast(list[Any], contents_obj)
text_parts: list[str] = []
for content in contents:
if isinstance(content, dict):
content_dict = cast(dict[str, Any], content)
# TextContent has a "text" key
if content_dict.get("type") == "text" or "text" in content_dict:
text_parts.append(str(content_dict.get("text", "")))
else:
content_obj: object = content
if hasattr(content_obj, "text"):
text_parts.append(str(getattr(content_obj, "text", "")))
if text_parts:
return " ".join(text_parts)
return ""
last_msg_obj: object = last_msg
if hasattr(last_msg_obj, "text"):
return str(getattr(last_msg_obj, "text", ""))
# Delegate to the shared extractor: messages can arrive in the
# flat "text" shape from this state's evaluators, the flat
# "content" shape from the fallback function module, or the
# structured .NET content list, and each must read cleanly.
return _extract_message_text(message_list[-1])
return ""

def _parse_function_args(self, args_str: str) -> list[str]:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,85 @@ def whole_call_args(formula: str, *names: str) -> str | None:
return formula[len(prefix) : close_index]
return None

def _content_items_text(items: Any) -> str:
"""Join the text entries of a structured content list.

Covers the .NET record shape (``[{"type": "text", "value": ...}]``,
capitalized keys included) and objects exposing a text attribute.
Records declaring a non-text type (image URLs, files) are skipped:
their ``value`` is a payload reference, not displayable text. Object
items are gated the same way core ``Message.text`` gates contents, so
a reasoning block's ``.text`` does not leak into the extraction.
"""
if not isinstance(items, list):
return ""
parts: list[str] = []
for item in cast(list[Any], items):
if isinstance(item, str):
parts.append(item)
elif isinstance(item, dict):
item_dict = cast(dict[str, Any], item)
declared_type = item_dict.get("type", item_dict.get("Type"))
if isinstance(declared_type, str) and declared_type.lower() != "text":
continue
for key in ("text", "value", "Text", "Value"):
if isinstance(item_dict.get(key), str):
parts.append(item_dict[key])
break
else:
declared_type = getattr(item, "type", None)
if isinstance(declared_type, str) and declared_type.lower() != "text":
continue
item_text = getattr(item, "text", None)
if item_text is not None:
parts.append(str(item_text))
return " ".join(parts)


def _extract_message_text(message: Any) -> str:
"""Extract the displayable text of a single message-shaped value.

Messages reach the evaluators in three shapes: flat ``content`` (this
module's producers), flat ``text`` (DeclarativeWorkflowState's custom
evaluators), and a structured content list (the .NET record shape).
Accept all three so a message that crosses eval paths never extracts
as empty. Top-level keys are probed in PascalCase too, because a
record serialized by .NET (``{"Role": ..., "Content": ...}``) keeps
its capitalized property names.
"""
if isinstance(message, str):
return message
if isinstance(message, dict):
message_dict = cast(dict[str, Any], message)
content: Any = message_dict.get("content", message_dict.get("Content"))
if isinstance(content, str):
return content
text: Any = message_dict.get("text", message_dict.get("Text"))
if isinstance(text, str):
return text
for key in ("content", "contents", "Content", "Contents"):
items_text = _content_items_text(message_dict.get(key))
if items_text:
return items_text
if content is not None and not isinstance(content, list | dict):
nested_text = getattr(content, "text", None)
if nested_text is not None:
return str(nested_text)
return str(content) if content else ""
return ""
text_attr = getattr(message, "text", None)
if text_attr is not None:
return str(text_attr)
content_attr = getattr(message, "content", None)
if isinstance(content_attr, str):
return content_attr
if content_attr is not None:
nested_attr = getattr(content_attr, "text", None)
if nested_attr is not None:
return str(nested_attr)
return str(content_attr) if content_attr else ""
return ""


def message_text(messages: Any) -> str:
"""Extract text content from a message or list of messages.
Expand All @@ -187,49 +266,20 @@ def message_text(messages: Any) -> str:

if isinstance(messages, dict):
# Single message object
messages_dict = cast(dict[str, Any], messages)
content: Any = messages_dict.get("content", "")
if isinstance(content, str):
return content
text_attr = getattr(content, "text", None)
if text_attr is not None:
return str(text_attr)
return str(content) if content else ""
return _extract_message_text(messages)

if isinstance(messages, list):
# List of messages - concatenate all text
texts: list[str] = []
message_list = cast(list[Any], messages)
for msg in message_list:
if isinstance(msg, str):
texts.append(msg)
elif isinstance(msg, dict):
msg_dict = cast(dict[str, Any], msg)
msg_content: Any = msg_dict.get("content", "")
if isinstance(msg_content, str):
texts.append(msg_content)
elif msg_content:
texts.append(str(msg_content))
else:
msg_obj: object = msg
if hasattr(msg_obj, "content"):
msg_obj_content: Any = getattr(msg_obj, "content", None)
if isinstance(msg_obj_content, str):
texts.append(msg_obj_content)
elif (msg_obj_text := getattr(msg_obj_content, "text", None)) is not None:
texts.append(str(msg_obj_text))
elif msg_obj_content:
texts.append(str(msg_obj_content))
extracted = _extract_message_text(msg)
if extracted:
texts.append(extracted)
return " ".join(texts)

# Try to get text attribute
if hasattr(messages, "text"):
return str(messages.text)
if hasattr(messages, "content"):
content_attr: Any = messages.content
if isinstance(content_attr, str):
return content_attr
return str(content_attr) if content_attr else ""
if hasattr(messages, "text") or hasattr(messages, "content"):
return _extract_message_text(messages)

return str(messages) if messages else ""

Expand Down
77 changes: 77 additions & 0 deletions python/packages/declarative/tests/test_powerfx_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,83 @@ def test_message_text_empty_list(self):
"""Test that empty list returns empty string."""
assert message_text([]) == ""

def test_message_text_accepts_text_key_shape(self):
"""DeclarativeWorkflowState producers emit a flat text key."""
assert message_text([{"role": "user", "text": "hi"}]) == "hi"

def test_message_text_accepts_structured_content_list(self):
"""The .NET record shape carries text in a content list."""
msg = {"role": "user", "content": [{"type": "text", "value": "hi"}]}
assert message_text([msg]) == "hi"

def test_message_text_accepts_capitalized_content_items(self):
"""System.Text.Json can surface PascalCase content item keys."""
msg = {"role": "user", "content": [{"Type": "Text", "Value": "hi"}]}
assert message_text([msg]) == "hi"

def test_message_text_accepts_pascalcase_top_level_content(self):
"""A .NET cross-runtime record keeps PascalCase top-level keys."""
msg = {"Role": "user", "Content": [{"Type": "Text", "Value": "hi"}]}
assert message_text([msg]) == "hi"

def test_message_text_accepts_pascalcase_flat_shapes(self):
"""PascalCase flat Content/Text strings extract like lowercase."""
assert message_text([{"Role": "user", "Content": "hi"}]) == "hi"
assert message_text([{"Role": "assistant", "Text": "hello"}]) == "hello"

def test_message_text_accepts_contents_list(self):
"""Message-dict shape stores content items under contents."""
msg = {"role": "assistant", "contents": [{"type": "text", "text": "hi"}]}
assert message_text([msg]) == "hi"

def test_message_text_skips_non_text_content_records(self):
"""Image/file records declare a non-text type; their value is not text."""
msg = {
"role": "user",
"content": [
{"type": "text", "value": "describe this"},
{"type": "image_url", "value": "https://example.com/cat.png"},
{"Type": "File", "Value": "data:application/pdf;base64,AAAA"},
],
}
assert message_text([msg]) == "describe this"

def test_message_text_skips_untyped_payload_records(self):
"""A record with neither a text type nor text keys stays empty."""
msg = {"role": "user", "content": [{"type": "image", "value": "..."}]}
assert message_text([msg]) == ""

def test_message_text_skips_reasoning_content_objects(self):
"""Core Content objects gate on type == "text", like Message.text."""
from agent_framework import Content

msg = {
"role": "assistant",
"content": [
Content.from_text_reasoning(text="chain of thought"),
Content.from_text(text="final answer"),
],
}
assert message_text([msg]) == "final answer"

def test_message_text_mixed_shapes_join(self):
"""A list mixing both flat shapes extracts every entry."""
msgs = [
{"role": "user", "text": "question"},
{"role": "assistant", "content": "answer"},
]
assert message_text(msgs) == "question answer"

def test_message_text_object_with_only_text_attr(self):
"""Objects exposing text but no content still contribute."""

class _TextOnly:
@property
def text(self):
return "hi"

assert message_text([_TextOnly()]) == "hi"


class TestUserMessage:
"""Tests for UserMessage function."""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,27 @@ async def test_message_text_empty_list(self, mock_state):
result = state.eval("=MessageText(Local.messages)")
assert result == ""

async def test_message_text_accepts_content_key_shape(self, mock_state):
"""Messages from the fallback evaluator carry a flat content key."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()

state.set("Local.messages", [{"role": "user", "content": "Hello"}])
result = state.eval("=MessageText(Local.messages)")
assert result == "Hello"

async def test_message_text_accepts_structured_content_list(self, mock_state):
"""Messages in the .NET record shape extract their content items."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()

state.set(
"Local.messages",
[{"role": "user", "content": [{"type": "text", "value": "Hello"}]}],
)
result = state.eval("=MessageText(Local.messages)")
assert result == "Hello"


class TestPowerFxNestedVariables:
"""Test nested variable access patterns from YAML."""
Expand Down
Loading