Skip to content

Commit 7db76dc

Browse files
feat: chat runtime support multiple triggers
1 parent 2d4a4f3 commit 7db76dc

File tree

3 files changed

+281
-21
lines changed

3 files changed

+281
-21
lines changed

src/uipath/runtime/chat/protocol.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
UiPathConversationMessageEvent,
77
)
88

9-
from uipath.runtime.result import UiPathRuntimeResult
9+
from uipath.runtime.resumable.trigger import UiPathResumeTrigger
1010

1111

1212
class UiPathChatProtocol(Protocol):
@@ -35,12 +35,12 @@ async def emit_message_event(
3535

3636
async def emit_interrupt_event(
3737
self,
38-
interrupt_event: UiPathRuntimeResult,
38+
resume_trigger: UiPathResumeTrigger,
3939
) -> None:
4040
"""Wrap and send an interrupt event.
4141
4242
Args:
43-
interrupt_event: UiPathConversationInterruptEvent to wrap and send
43+
resume_trigger: UiPathResumeTrigger to wrap and send
4444
"""
4545
...
4646

src/uipath/runtime/chat/runtime.py

Lines changed: 28 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -86,25 +86,41 @@ 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
93+
for t in runtime_result.triggers
94+
if t.trigger_type == UiPathResumeTriggerType.API
95+
]
96+
97+
if api_triggers:
98+
resume_map: dict[str, Any] = {}
99+
100+
for trigger in api_triggers:
101+
await self.chat_bridge.emit_interrupt_event(trigger)
102+
103+
resume_data = await self.chat_bridge.wait_for_resume()
104+
105+
assert trigger.interrupt_id is not None, (
106+
"Trigger interrupt_id cannot be None"
107+
)
108+
resume_map[trigger.interrupt_id] = resume_data
109+
110+
current_input = resume_map
111+
current_options.resume = True
112+
break
113+
else:
114+
# No API triggers - yield result and complete
115+
yield event
116+
execution_completed = True
100117
else:
101118
yield event
102119
execution_completed = True
120+
await self.chat_bridge.emit_exchange_end_event()
103121
else:
104122
yield event
105123

106-
await self.chat_bridge.emit_exchange_end_event()
107-
108124
async def get_schema(self) -> UiPathRuntimeSchema:
109125
"""Get schema from the delegate runtime."""
110126
return await self.delegate.get_schema()

tests/test_chat.py

Lines changed: 250 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,10 @@ 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 == {
328+
"resumed": True,
329+
"input": {"interrupt-1": {"approved": True}},
330+
}
326331

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

0 commit comments

Comments
 (0)