Skip to content

Commit 1edc828

Browse files
feat: chat runtime support multiple triggers
1 parent 189bfde commit 1edc828

4 files changed

Lines changed: 276 additions & 18 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "uipath-runtime"
3-
version = "0.6.2"
3+
version = "0.6.3"
44
description = "Runtime abstractions and interfaces for building agents and automation scripts in the UiPath ecosystem"
55
readme = { file = "README.md", content-type = "text/markdown" }
66
requires-python = ">=3.11"

src/uipath/runtime/chat/runtime.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,17 +86,40 @@ async def stream(
8686

8787
if (
8888
runtime_result.status == UiPathRuntimeStatus.SUSPENDED
89-
and runtime_result.trigger
90-
and runtime_result.trigger.trigger_type
91-
== UiPathResumeTriggerType.API
89+
and runtime_result.triggers
9290
):
93-
await self.chat_bridge.emit_interrupt_event(runtime_result)
94-
resume_data = await self.chat_bridge.wait_for_resume()
95-
96-
# Continue with resumed execution
97-
current_input = resume_data
98-
current_options.resume = True
99-
break
91+
api_triggers = [
92+
t for t in runtime_result.triggers
93+
if t.trigger_type == UiPathResumeTriggerType.API
94+
]
95+
96+
if api_triggers:
97+
resume_map: dict[str, Any] = {}
98+
99+
for trigger in api_triggers:
100+
single_trigger_result = UiPathRuntimeResult(
101+
status=UiPathRuntimeStatus.SUSPENDED,
102+
output=runtime_result.output,
103+
trigger=trigger,
104+
triggers=[trigger],
105+
)
106+
107+
# Emit startInterrupt event
108+
await self.chat_bridge.emit_interrupt_event(
109+
single_trigger_result
110+
)
111+
112+
resume_data = await self.chat_bridge.wait_for_resume()
113+
114+
resume_map[trigger.interrupt_id] = resume_data
115+
116+
current_input = resume_map
117+
current_options.resume = True
118+
break
119+
else:
120+
# No API triggers - yield result and complete
121+
yield event
122+
execution_completed = True
100123
else:
101124
yield event
102125
execution_completed = True

tests/test_chat.py

Lines changed: 241 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,13 +142,15 @@ async def stream(
142142

143143
if self.suspend_at_message is not None:
144144
# Suspend with API trigger
145+
trigger = UiPathResumeTrigger(
146+
interrupt_id="interrupt-1",
147+
trigger_type=UiPathResumeTriggerType.API,
148+
payload={"action": "confirm_tool_call"},
149+
)
145150
yield UiPathRuntimeResult(
146151
status=UiPathRuntimeStatus.SUSPENDED,
147-
trigger=UiPathResumeTrigger(
148-
interrupt_id="interrupt-1",
149-
trigger_type=UiPathResumeTriggerType.API,
150-
payload={"action": "confirm_tool_call"},
151-
),
152+
trigger=trigger,
153+
triggers=[trigger],
152154
)
153155
return
154156
else:
@@ -322,7 +324,7 @@ async def test_chat_runtime_handles_api_trigger_suspension():
322324
# Result should be SUCCESSFUL
323325
assert isinstance(result, UiPathRuntimeResult)
324326
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
325-
assert result.output == {"resumed": True, "input": {"approved": True}}
327+
assert result.output == {"resumed": True, "input": {"interrupt-1": {"approved": True}}}
326328

327329
cast(AsyncMock, bridge.connect).assert_awaited_once()
328330
cast(AsyncMock, bridge.disconnect).assert_awaited_once()
@@ -369,3 +371,236 @@ async def test_chat_runtime_yields_events_during_suspension_flow():
369371
for event in events:
370372
if isinstance(event, UiPathRuntimeResult):
371373
assert event.status != UiPathRuntimeStatus.SUSPENDED
374+
375+
376+
class MultiTriggerMockRuntime:
377+
"""Mock runtime that suspends with multiple API triggers."""
378+
379+
def __init__(self) -> None:
380+
self.execution_count = 0
381+
382+
async def dispose(self) -> None:
383+
pass
384+
385+
async def execute(
386+
self,
387+
input: dict[str, Any] | None = None,
388+
options: UiPathExecuteOptions | None = None,
389+
) -> UiPathRuntimeResult:
390+
"""Execute with multiple trigger suspension."""
391+
result: UiPathRuntimeResult | None = None
392+
async for event in self.stream(input, cast(UiPathStreamOptions, options)):
393+
if isinstance(event, UiPathRuntimeResult):
394+
result = event
395+
return result if result else UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL)
396+
397+
async def stream(
398+
self,
399+
input: dict[str, Any] | None = None,
400+
options: UiPathStreamOptions | None = None,
401+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
402+
"""Stream with multiple trigger suspension."""
403+
self.execution_count += 1
404+
is_resume = options and options.resume
405+
406+
if not is_resume:
407+
# Initial execution - suspend with 3 API triggers
408+
trigger_a = UiPathResumeTrigger(
409+
interrupt_id="email-confirm",
410+
trigger_type=UiPathResumeTriggerType.API,
411+
payload={"action": "send_email", "to": "user@example.com"},
412+
)
413+
trigger_b = UiPathResumeTrigger(
414+
interrupt_id="file-delete",
415+
trigger_type=UiPathResumeTriggerType.API,
416+
payload={"action": "delete_file", "path": "/logs/old.txt"},
417+
)
418+
trigger_c = UiPathResumeTrigger(
419+
interrupt_id="api-call",
420+
trigger_type=UiPathResumeTriggerType.API,
421+
payload={"action": "call_api", "endpoint": "/users"},
422+
)
423+
424+
yield UiPathRuntimeResult(
425+
status=UiPathRuntimeStatus.SUSPENDED,
426+
trigger=trigger_a,
427+
triggers=[trigger_a, trigger_b, trigger_c],
428+
)
429+
else:
430+
# Resumed - verify all triggers resolved
431+
assert input is not None
432+
assert "email-confirm" in input
433+
assert "file-delete" in input
434+
assert "api-call" in input
435+
436+
yield UiPathRuntimeResult(
437+
status=UiPathRuntimeStatus.SUCCESSFUL,
438+
output={"resumed": True, "input": input},
439+
)
440+
441+
async def get_schema(self) -> UiPathRuntimeSchema:
442+
raise NotImplementedError()
443+
444+
445+
class MixedTriggerMockRuntime:
446+
"""Mock runtime that suspends with mixed trigger types (API + non-API)."""
447+
448+
async def dispose(self) -> None:
449+
pass
450+
451+
async def execute(
452+
self,
453+
input: dict[str, Any] | None = None,
454+
options: UiPathExecuteOptions | None = None,
455+
) -> UiPathRuntimeResult:
456+
"""Execute with mixed triggers."""
457+
result: UiPathRuntimeResult | None = None
458+
async for event in self.stream(input, cast(UiPathStreamOptions, options)):
459+
if isinstance(event, UiPathRuntimeResult):
460+
result = event
461+
return result if result else UiPathRuntimeResult(status=UiPathRuntimeStatus.SUCCESSFUL)
462+
463+
async def stream(
464+
self,
465+
input: dict[str, Any] | None = None,
466+
options: UiPathStreamOptions | None = None,
467+
) -> AsyncGenerator[UiPathRuntimeEvent, None]:
468+
"""Stream with mixed trigger types."""
469+
is_resume = options and options.resume
470+
471+
if not is_resume:
472+
# Initial execution - 2 API + 1 QUEUE trigger
473+
trigger_a = UiPathResumeTrigger(
474+
interrupt_id="email-confirm",
475+
trigger_type=UiPathResumeTriggerType.API,
476+
payload={"action": "send_email"},
477+
)
478+
trigger_b = UiPathResumeTrigger(
479+
interrupt_id="file-delete",
480+
trigger_type=UiPathResumeTriggerType.API,
481+
payload={"action": "delete_file"},
482+
)
483+
trigger_c = UiPathResumeTrigger(
484+
interrupt_id="queue-item",
485+
trigger_type=UiPathResumeTriggerType.QUEUE_ITEM,
486+
payload={"queue": "inbox", "item": "123"},
487+
)
488+
489+
yield UiPathRuntimeResult(
490+
status=UiPathRuntimeStatus.SUSPENDED,
491+
trigger=trigger_a,
492+
triggers=[trigger_a, trigger_b, trigger_c],
493+
)
494+
else:
495+
# Resumed - verify only API triggers resolved
496+
assert input is not None
497+
assert "email-confirm" in input
498+
assert "file-delete" in input
499+
# QUEUE trigger should NOT be in input (handled externally)
500+
501+
# Suspend again with only QUEUE trigger
502+
trigger_c = UiPathResumeTrigger(
503+
interrupt_id="queue-item",
504+
trigger_type=UiPathResumeTriggerType.QUEUE_ITEM,
505+
payload={"queue": "inbox", "item": "123"},
506+
)
507+
508+
yield UiPathRuntimeResult(
509+
status=UiPathRuntimeStatus.SUSPENDED,
510+
trigger=trigger_c,
511+
triggers=[trigger_c],
512+
)
513+
514+
async def get_schema(self) -> UiPathRuntimeSchema:
515+
raise NotImplementedError()
516+
517+
518+
@pytest.mark.asyncio
519+
async def test_chat_runtime_handles_multiple_api_triggers():
520+
"""ChatRuntime should resolve all API triggers before resuming."""
521+
522+
runtime_impl = MultiTriggerMockRuntime()
523+
bridge = make_chat_bridge_mock()
524+
525+
# Bridge returns approval for each trigger
526+
cast(AsyncMock, bridge.wait_for_resume).side_effect = [
527+
{"approved": True}, # email-confirm
528+
{"approved": True}, # file-delete
529+
{"approved": True}, # api-call
530+
]
531+
532+
chat_runtime = UiPathChatRuntime(
533+
delegate=runtime_impl,
534+
chat_bridge=bridge,
535+
)
536+
537+
result = await chat_runtime.execute({})
538+
539+
await chat_runtime.dispose()
540+
541+
# Result should be SUCCESSFUL
542+
assert result.status == UiPathRuntimeStatus.SUCCESSFUL
543+
assert result.output["resumed"] is True
544+
545+
# Verify all 3 triggers were wrapped with interrupt_ids
546+
resume_input = result.output["input"]
547+
assert "email-confirm" in resume_input
548+
assert "file-delete" in resume_input
549+
assert "api-call" in resume_input
550+
assert resume_input["email-confirm"] == {"approved": True}
551+
assert resume_input["file-delete"] == {"approved": True}
552+
assert resume_input["api-call"] == {"approved": True}
553+
554+
# Bridge should have been called 3 times (once per trigger)
555+
assert cast(AsyncMock, bridge.emit_interrupt_event).await_count == 3
556+
assert cast(AsyncMock, bridge.wait_for_resume).await_count == 3
557+
558+
# Verify each emit_interrupt_event received a single-trigger result
559+
emit_calls = cast(AsyncMock, bridge.emit_interrupt_event).await_args_list
560+
assert len(emit_calls[0][0][0].triggers) == 1 # First call has 1 trigger
561+
assert emit_calls[0][0][0].triggers[0].interrupt_id == "email-confirm"
562+
assert len(emit_calls[1][0][0].triggers) == 1 # Second call has 1 trigger
563+
assert emit_calls[1][0][0].triggers[0].interrupt_id == "file-delete"
564+
assert len(emit_calls[2][0][0].triggers) == 1 # Third call has 1 trigger
565+
assert emit_calls[2][0][0].triggers[0].interrupt_id == "api-call"
566+
567+
568+
@pytest.mark.asyncio
569+
async def test_chat_runtime_filters_non_api_triggers():
570+
"""ChatRuntime should only handle API triggers, pass through non-API triggers."""
571+
572+
runtime_impl = MixedTriggerMockRuntime()
573+
bridge = make_chat_bridge_mock()
574+
575+
# Bridge returns approval for API triggers only
576+
cast(AsyncMock, bridge.wait_for_resume).side_effect = [
577+
{"approved": True}, # email-confirm
578+
{"approved": True}, # file-delete
579+
]
580+
581+
chat_runtime = UiPathChatRuntime(
582+
delegate=runtime_impl,
583+
chat_bridge=bridge,
584+
)
585+
586+
result = await chat_runtime.execute({})
587+
588+
await chat_runtime.dispose()
589+
590+
# Result should be SUSPENDED with QUEUE trigger (non-API)
591+
assert result.status == UiPathRuntimeStatus.SUSPENDED
592+
assert result.triggers is not None
593+
assert len(result.triggers) == 1
594+
assert result.triggers[0].interrupt_id == "queue-item"
595+
assert result.triggers[0].trigger_type == UiPathResumeTriggerType.QUEUE_ITEM
596+
597+
# Bridge should have been called only 2 times (for 2 API triggers)
598+
assert cast(AsyncMock, bridge.emit_interrupt_event).await_count == 2
599+
assert cast(AsyncMock, bridge.wait_for_resume).await_count == 2
600+
601+
# Verify only API triggers were emitted
602+
emit_calls = cast(AsyncMock, bridge.emit_interrupt_event).await_args_list
603+
assert emit_calls[0][0][0].triggers[0].interrupt_id == "email-confirm"
604+
assert emit_calls[0][0][0].triggers[0].trigger_type == UiPathResumeTriggerType.API
605+
assert emit_calls[1][0][0].triggers[0].interrupt_id == "file-delete"
606+
assert emit_calls[1][0][0].triggers[0].trigger_type == UiPathResumeTriggerType.API

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)