|
12 | 12 | AgentThread, |
13 | 13 | ChatMessage, |
14 | 14 | ChatMessageStore, |
| 15 | + DataContent, |
15 | 16 | Executor, |
16 | 17 | FunctionApprovalRequestContent, |
17 | 18 | FunctionApprovalResponseContent, |
18 | 19 | FunctionCallContent, |
19 | 20 | Role, |
20 | 21 | TextContent, |
| 22 | + UriContent, |
21 | 23 | UsageContent, |
22 | 24 | UsageDetails, |
23 | 25 | WorkflowAgent, |
24 | 26 | WorkflowBuilder, |
25 | 27 | WorkflowContext, |
| 28 | + executor, |
26 | 29 | handler, |
27 | 30 | response_handler, |
28 | 31 | ) |
@@ -284,6 +287,141 @@ async def handle_bool(self, message: bool, context: WorkflowContext[Any]) -> Non |
284 | 287 | with pytest.raises(ValueError, match="Workflow's start executor cannot handle list\\[ChatMessage\\]"): |
285 | 288 | workflow.as_agent() |
286 | 289 |
|
| 290 | + async def test_workflow_as_agent_yield_output_surfaces_as_agent_response(self) -> None: |
| 291 | + """Test that ctx.yield_output() in a workflow executor surfaces as agent output when using .as_agent(). |
| 292 | +
|
| 293 | + This validates the fix for issue #2813: WorkflowOutputEvent should be converted to |
| 294 | + AgentRunResponseUpdate when the workflow is wrapped via .as_agent(). |
| 295 | + """ |
| 296 | + |
| 297 | + @executor |
| 298 | + async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None: |
| 299 | + # Extract text from input for demonstration |
| 300 | + input_text = messages[0].text if messages else "no input" |
| 301 | + await ctx.yield_output(f"processed: {input_text}") |
| 302 | + |
| 303 | + workflow = WorkflowBuilder().set_start_executor(yielding_executor).build() |
| 304 | + |
| 305 | + # Run directly - should return WorkflowOutputEvent in result |
| 306 | + direct_result = await workflow.run([ChatMessage(role=Role.USER, contents=[TextContent(text="hello")])]) |
| 307 | + direct_outputs = direct_result.get_outputs() |
| 308 | + assert len(direct_outputs) == 1 |
| 309 | + assert direct_outputs[0] == "processed: hello" |
| 310 | + |
| 311 | + # Run as agent - yield_output should surface as agent response message |
| 312 | + agent = workflow.as_agent("test-agent") |
| 313 | + agent_result = await agent.run("hello") |
| 314 | + |
| 315 | + assert isinstance(agent_result, AgentRunResponse) |
| 316 | + assert len(agent_result.messages) == 1 |
| 317 | + assert agent_result.messages[0].text == "processed: hello" |
| 318 | + |
| 319 | + async def test_workflow_as_agent_yield_output_surfaces_in_run_stream(self) -> None: |
| 320 | + """Test that ctx.yield_output() surfaces as AgentRunResponseUpdate when streaming.""" |
| 321 | + |
| 322 | + @executor |
| 323 | + async def yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None: |
| 324 | + await ctx.yield_output("first output") |
| 325 | + await ctx.yield_output("second output") |
| 326 | + |
| 327 | + workflow = WorkflowBuilder().set_start_executor(yielding_executor).build() |
| 328 | + agent = workflow.as_agent("test-agent") |
| 329 | + |
| 330 | + updates: list[AgentRunResponseUpdate] = [] |
| 331 | + async for update in agent.run_stream("hello"): |
| 332 | + updates.append(update) |
| 333 | + |
| 334 | + # Should have received updates for both yield_output calls |
| 335 | + texts = [u.text for u in updates if u.text] |
| 336 | + assert "first output" in texts |
| 337 | + assert "second output" in texts |
| 338 | + |
| 339 | + async def test_workflow_as_agent_yield_output_with_content_types(self) -> None: |
| 340 | + """Test that yield_output preserves different content types (TextContent, DataContent, etc.).""" |
| 341 | + |
| 342 | + @executor |
| 343 | + async def content_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None: |
| 344 | + # Yield different content types |
| 345 | + await ctx.yield_output(TextContent(text="text content")) |
| 346 | + await ctx.yield_output(DataContent(data=b"binary data", media_type="application/octet-stream")) |
| 347 | + await ctx.yield_output(UriContent(uri="https://example.com/image.png", media_type="image/png")) |
| 348 | + |
| 349 | + workflow = WorkflowBuilder().set_start_executor(content_yielding_executor).build() |
| 350 | + agent = workflow.as_agent("content-test-agent") |
| 351 | + |
| 352 | + result = await agent.run("test") |
| 353 | + |
| 354 | + assert isinstance(result, AgentRunResponse) |
| 355 | + assert len(result.messages) == 3 |
| 356 | + |
| 357 | + # Verify each content type is preserved |
| 358 | + assert isinstance(result.messages[0].contents[0], TextContent) |
| 359 | + assert result.messages[0].contents[0].text == "text content" |
| 360 | + |
| 361 | + assert isinstance(result.messages[1].contents[0], DataContent) |
| 362 | + assert result.messages[1].contents[0].media_type == "application/octet-stream" |
| 363 | + |
| 364 | + assert isinstance(result.messages[2].contents[0], UriContent) |
| 365 | + assert result.messages[2].contents[0].uri == "https://example.com/image.png" |
| 366 | + |
| 367 | + async def test_workflow_as_agent_yield_output_with_chat_message(self) -> None: |
| 368 | + """Test that yield_output with ChatMessage preserves the message structure.""" |
| 369 | + |
| 370 | + @executor |
| 371 | + async def chat_message_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None: |
| 372 | + msg = ChatMessage( |
| 373 | + role=Role.ASSISTANT, |
| 374 | + contents=[TextContent(text="response text")], |
| 375 | + author_name="custom-author", |
| 376 | + ) |
| 377 | + await ctx.yield_output(msg) |
| 378 | + |
| 379 | + workflow = WorkflowBuilder().set_start_executor(chat_message_executor).build() |
| 380 | + agent = workflow.as_agent("chat-msg-agent") |
| 381 | + |
| 382 | + result = await agent.run("test") |
| 383 | + |
| 384 | + assert len(result.messages) == 1 |
| 385 | + assert result.messages[0].role == Role.ASSISTANT |
| 386 | + assert result.messages[0].text == "response text" |
| 387 | + assert result.messages[0].author_name == "custom-author" |
| 388 | + |
| 389 | + async def test_workflow_as_agent_yield_output_sets_raw_representation(self) -> None: |
| 390 | + """Test that yield_output sets raw_representation with the original data.""" |
| 391 | + |
| 392 | + # A custom object to verify raw_representation preserves the original data |
| 393 | + class CustomData: |
| 394 | + def __init__(self, value: int): |
| 395 | + self.value = value |
| 396 | + |
| 397 | + def __str__(self) -> str: |
| 398 | + return f"CustomData({self.value})" |
| 399 | + |
| 400 | + @executor |
| 401 | + async def raw_yielding_executor(messages: list[ChatMessage], ctx: WorkflowContext) -> None: |
| 402 | + # Yield different types of data |
| 403 | + await ctx.yield_output("simple string") |
| 404 | + await ctx.yield_output(TextContent(text="text content")) |
| 405 | + custom = CustomData(42) |
| 406 | + await ctx.yield_output(custom) |
| 407 | + |
| 408 | + workflow = WorkflowBuilder().set_start_executor(raw_yielding_executor).build() |
| 409 | + agent = workflow.as_agent("raw-test-agent") |
| 410 | + |
| 411 | + updates: list[AgentRunResponseUpdate] = [] |
| 412 | + async for update in agent.run_stream("test"): |
| 413 | + updates.append(update) |
| 414 | + |
| 415 | + # Should have 3 updates |
| 416 | + assert len(updates) == 3 |
| 417 | + |
| 418 | + # Verify raw_representation is set for each update |
| 419 | + assert updates[0].raw_representation == "simple string" |
| 420 | + assert isinstance(updates[1].raw_representation, TextContent) |
| 421 | + assert updates[1].raw_representation.text == "text content" |
| 422 | + assert isinstance(updates[2].raw_representation, CustomData) |
| 423 | + assert updates[2].raw_representation.value == 42 |
| 424 | + |
287 | 425 | async def test_thread_conversation_history_included_in_workflow_run(self) -> None: |
288 | 426 | """Test that conversation history from thread is included when running WorkflowAgent. |
289 | 427 |
|
|
0 commit comments