vellum-ai 1.7.6__py3-none-any.whl → 1.7.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vellum/client/core/client_wrapper.py +2 -2
- vellum/client/reference.md +16 -0
- vellum/client/resources/ad_hoc/raw_client.py +2 -2
- vellum/client/resources/integration_providers/client.py +20 -0
- vellum/client/resources/integration_providers/raw_client.py +20 -0
- vellum/client/types/integration_name.py +1 -0
- vellum/client/types/workflow_execution_fulfilled_body.py +1 -0
- vellum/workflows/nodes/bases/base_adornment_node.py +53 -1
- vellum/workflows/nodes/core/map_node/node.py +10 -0
- vellum/workflows/nodes/core/templating_node/tests/test_templating_node.py +49 -1
- vellum/workflows/nodes/displayable/bases/inline_prompt_node/node.py +3 -1
- vellum/workflows/nodes/tests/test_utils.py +7 -1
- vellum/workflows/nodes/utils.py +1 -1
- vellum/workflows/references/__init__.py +2 -0
- vellum/workflows/references/trigger.py +83 -0
- vellum/workflows/runner/runner.py +17 -15
- vellum/workflows/state/base.py +49 -1
- vellum/workflows/triggers/__init__.py +2 -1
- vellum/workflows/triggers/base.py +140 -3
- vellum/workflows/triggers/integration.py +31 -26
- vellum/workflows/triggers/slack.py +101 -0
- vellum/workflows/triggers/tests/test_integration.py +55 -31
- vellum/workflows/triggers/tests/test_slack.py +180 -0
- {vellum_ai-1.7.6.dist-info → vellum_ai-1.7.8.dist-info}/METADATA +1 -1
- {vellum_ai-1.7.6.dist-info → vellum_ai-1.7.8.dist-info}/RECORD +34 -30
- vellum_ee/workflows/display/base.py +3 -0
- vellum_ee/workflows/display/nodes/base_node_display.py +1 -1
- vellum_ee/workflows/display/tests/workflow_serialization/generic_nodes/test_attributes_serialization.py +16 -0
- vellum_ee/workflows/display/tests/workflow_serialization/test_slack_trigger_serialization.py +167 -0
- vellum_ee/workflows/display/utils/expressions.py +11 -11
- vellum_ee/workflows/display/workflows/base_workflow_display.py +22 -6
- {vellum_ai-1.7.6.dist-info → vellum_ai-1.7.8.dist-info}/LICENSE +0 -0
- {vellum_ai-1.7.6.dist-info → vellum_ai-1.7.8.dist-info}/WHEEL +0 -0
- {vellum_ai-1.7.6.dist-info → vellum_ai-1.7.8.dist-info}/entry_points.txt +0 -0
@@ -1,11 +1,58 @@
|
|
1
1
|
from abc import ABC, ABCMeta
|
2
|
-
|
2
|
+
import inspect
|
3
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Iterator, Tuple, Type, cast, get_origin
|
3
4
|
|
4
5
|
if TYPE_CHECKING:
|
5
6
|
from vellum.workflows.graph.graph import Graph, GraphTarget
|
7
|
+
from vellum.workflows.state.base import BaseState
|
8
|
+
|
9
|
+
from vellum.workflows.references.trigger import TriggerAttributeReference
|
10
|
+
from vellum.workflows.types.utils import get_class_attr_names, infer_types
|
11
|
+
from vellum.workflows.utils.uuids import uuid4_from_hash
|
12
|
+
|
13
|
+
|
14
|
+
def _is_annotated(cls: Type, name: str) -> bool:
|
15
|
+
annotations = getattr(cls, "__annotations__", {})
|
16
|
+
annotation = annotations.get(name)
|
17
|
+
if annotation is not None:
|
18
|
+
if get_origin(annotation) is ClassVar:
|
19
|
+
return False
|
20
|
+
return True
|
21
|
+
|
22
|
+
for base in cls.__bases__:
|
23
|
+
if _is_annotated(base, name):
|
24
|
+
return True
|
25
|
+
|
26
|
+
return False
|
6
27
|
|
7
28
|
|
8
29
|
class BaseTriggerMeta(ABCMeta):
|
30
|
+
def __new__(mcs, name: str, bases: Tuple[Type, ...], dct: Dict[str, Any]) -> Any:
|
31
|
+
cls = super().__new__(mcs, name, bases, dct)
|
32
|
+
trigger_cls = cast(Type["BaseTrigger"], cls)
|
33
|
+
|
34
|
+
attribute_ids: Dict[str, Any] = {}
|
35
|
+
for base in bases:
|
36
|
+
base_ids = getattr(base, "__trigger_attribute_ids__", None)
|
37
|
+
if isinstance(base_ids, dict):
|
38
|
+
attribute_ids.update(base_ids)
|
39
|
+
|
40
|
+
annotations = getattr(trigger_cls, "__annotations__", {})
|
41
|
+
for attr_name, annotation in annotations.items():
|
42
|
+
if attr_name.startswith("_"):
|
43
|
+
continue
|
44
|
+
if attr_name in trigger_cls.__dict__:
|
45
|
+
continue
|
46
|
+
if get_origin(annotation) is ClassVar:
|
47
|
+
continue
|
48
|
+
attribute_ids[attr_name] = uuid4_from_hash(
|
49
|
+
f"{trigger_cls.__module__}|{trigger_cls.__qualname__}|{attr_name}"
|
50
|
+
)
|
51
|
+
|
52
|
+
trigger_cls.__trigger_attribute_ids__ = attribute_ids
|
53
|
+
trigger_cls.__trigger_attribute_cache__ = {}
|
54
|
+
return trigger_cls
|
55
|
+
|
9
56
|
"""
|
10
57
|
Metaclass for BaseTrigger that enables class-level >> operator.
|
11
58
|
|
@@ -13,6 +60,68 @@ class BaseTriggerMeta(ABCMeta):
|
|
13
60
|
ManualTrigger >> MyNode # Class-level, no instantiation
|
14
61
|
"""
|
15
62
|
|
63
|
+
def __getattribute__(cls, name: str) -> Any:
|
64
|
+
trigger_cls = cast(Type["BaseTrigger"], cls)
|
65
|
+
if name.startswith("_"):
|
66
|
+
return super().__getattribute__(name)
|
67
|
+
|
68
|
+
try:
|
69
|
+
attribute = super().__getattribute__(name)
|
70
|
+
except AttributeError as exc:
|
71
|
+
if _is_annotated(cls, name):
|
72
|
+
attribute = None
|
73
|
+
else:
|
74
|
+
raise exc
|
75
|
+
|
76
|
+
if inspect.isroutine(attribute) or isinstance(attribute, (property, classmethod, staticmethod)):
|
77
|
+
return attribute
|
78
|
+
|
79
|
+
if isinstance(attribute, TriggerAttributeReference):
|
80
|
+
return attribute
|
81
|
+
|
82
|
+
if name in cls.__dict__:
|
83
|
+
return attribute
|
84
|
+
|
85
|
+
if not _is_annotated(cls, name):
|
86
|
+
return attribute
|
87
|
+
|
88
|
+
cache = getattr(cls, "__trigger_attribute_cache__", {})
|
89
|
+
if name in cache:
|
90
|
+
return cache[name]
|
91
|
+
|
92
|
+
for base in cls.__mro__[1:]:
|
93
|
+
base_cache = getattr(base, "__trigger_attribute_cache__", None)
|
94
|
+
if base_cache and name in base_cache:
|
95
|
+
cache[name] = base_cache[name]
|
96
|
+
return base_cache[name]
|
97
|
+
|
98
|
+
types = infer_types(cls, name)
|
99
|
+
reference = TriggerAttributeReference(name=name, types=types, instance=attribute, trigger_class=trigger_cls)
|
100
|
+
cache[name] = reference
|
101
|
+
cls.__trigger_attribute_cache__ = cache
|
102
|
+
return reference
|
103
|
+
|
104
|
+
def __iter__(cls) -> Iterator[TriggerAttributeReference]:
|
105
|
+
cache = getattr(cls, "__trigger_attribute_cache__", {})
|
106
|
+
seen: Dict[str, TriggerAttributeReference] = dict(cache)
|
107
|
+
|
108
|
+
for base in cls.__mro__[1:]:
|
109
|
+
base_cache = getattr(base, "__trigger_attribute_cache__", None)
|
110
|
+
if not base_cache:
|
111
|
+
continue
|
112
|
+
for key, value in base_cache.items():
|
113
|
+
seen.setdefault(key, value)
|
114
|
+
|
115
|
+
for attr_name in get_class_attr_names(cls):
|
116
|
+
if attr_name in seen:
|
117
|
+
yield seen[attr_name]
|
118
|
+
continue
|
119
|
+
|
120
|
+
attr_value = getattr(cls, attr_name)
|
121
|
+
if isinstance(attr_value, TriggerAttributeReference):
|
122
|
+
seen[attr_name] = attr_value
|
123
|
+
yield attr_value
|
124
|
+
|
16
125
|
def __rshift__(cls, other: "GraphTarget") -> "Graph": # type: ignore[misc]
|
17
126
|
"""
|
18
127
|
Enable Trigger class >> Node syntax (class-level only).
|
@@ -33,7 +142,7 @@ class BaseTriggerMeta(ABCMeta):
|
|
33
142
|
from vellum.workflows.nodes.bases.base import BaseNode as BaseNodeClass
|
34
143
|
|
35
144
|
# Cast cls to the proper type for TriggerEdge
|
36
|
-
trigger_cls = cast(
|
145
|
+
trigger_cls = cast(Type["BaseTrigger"], cls)
|
37
146
|
|
38
147
|
if isinstance(other, set):
|
39
148
|
# Trigger >> {NodeA, NodeB}
|
@@ -122,4 +231,32 @@ class BaseTrigger(ABC, metaclass=BaseTriggerMeta):
|
|
122
231
|
Like nodes, triggers work at the class level only. Do not instantiate triggers.
|
123
232
|
"""
|
124
233
|
|
125
|
-
|
234
|
+
__trigger_attribute_ids__: Dict[str, Any]
|
235
|
+
__trigger_attribute_cache__: Dict[str, "TriggerAttributeReference[Any]"]
|
236
|
+
|
237
|
+
@classmethod
|
238
|
+
def attribute_references(cls) -> Dict[str, "TriggerAttributeReference[Any]"]:
|
239
|
+
"""Return class-level trigger attribute descriptors keyed by attribute name."""
|
240
|
+
|
241
|
+
return {reference.name: reference for reference in cls}
|
242
|
+
|
243
|
+
def to_trigger_attribute_values(self) -> Dict["TriggerAttributeReference[Any]", Any]:
|
244
|
+
"""Materialize attribute descriptor/value pairs for this trigger instance."""
|
245
|
+
|
246
|
+
attribute_values: Dict["TriggerAttributeReference[Any]", Any] = {}
|
247
|
+
for reference in type(self):
|
248
|
+
if hasattr(self, reference.name):
|
249
|
+
attribute_values[reference] = getattr(self, reference.name)
|
250
|
+
elif type(None) in reference.types:
|
251
|
+
attribute_values[reference] = None
|
252
|
+
else:
|
253
|
+
raise AttributeError(
|
254
|
+
f"{type(self).__name__} did not populate required trigger attribute '{reference.name}'"
|
255
|
+
)
|
256
|
+
|
257
|
+
return attribute_values
|
258
|
+
|
259
|
+
def bind_to_state(self, state: "BaseState") -> None:
|
260
|
+
"""Persist this trigger's attribute values onto the provided state."""
|
261
|
+
|
262
|
+
state.meta.trigger_attributes.update(self.to_trigger_attribute_values())
|
@@ -1,7 +1,6 @@
|
|
1
1
|
from abc import ABC
|
2
2
|
from typing import ClassVar, Optional
|
3
3
|
|
4
|
-
from vellum.workflows.outputs.base import BaseOutputs
|
5
4
|
from vellum.workflows.triggers.base import BaseTrigger
|
6
5
|
|
7
6
|
|
@@ -11,52 +10,58 @@ class IntegrationTrigger(BaseTrigger, ABC):
|
|
11
10
|
|
12
11
|
Integration triggers:
|
13
12
|
- Are initiated by external events (webhooks, API calls)
|
14
|
-
- Produce
|
13
|
+
- Produce attributes that downstream nodes can reference
|
15
14
|
- Require configuration (auth, webhooks, etc.)
|
16
15
|
|
17
16
|
Examples:
|
18
17
|
# Define an integration trigger
|
19
18
|
class MyIntegrationTrigger(IntegrationTrigger):
|
20
|
-
|
21
|
-
data: str
|
19
|
+
data: str
|
22
20
|
|
23
|
-
|
24
|
-
|
25
|
-
|
21
|
+
def __init__(self, event_data: dict):
|
22
|
+
super().__init__(event_data)
|
23
|
+
self.data = event_data.get("data", "")
|
26
24
|
|
27
25
|
# Use in workflow
|
28
26
|
class MyWorkflow(BaseWorkflow):
|
29
27
|
graph = MyIntegrationTrigger >> ProcessNode
|
30
28
|
|
29
|
+
# Reference trigger attributes in nodes
|
30
|
+
class ProcessNode(BaseNode):
|
31
|
+
class Outputs(BaseNode.Outputs):
|
32
|
+
result = MyIntegrationTrigger.data
|
33
|
+
|
31
34
|
Note:
|
32
|
-
Unlike ManualTrigger, integration triggers provide structured
|
33
|
-
that downstream nodes can reference directly
|
35
|
+
Unlike ManualTrigger, integration triggers provide structured attributes
|
36
|
+
that downstream nodes can reference directly.
|
34
37
|
"""
|
35
38
|
|
36
|
-
class Outputs(BaseOutputs):
|
37
|
-
"""Base outputs for integration triggers."""
|
38
|
-
|
39
|
-
pass
|
40
|
-
|
41
39
|
# Configuration that can be set at runtime
|
42
40
|
config: ClassVar[Optional[dict]] = None
|
43
41
|
|
44
|
-
|
45
|
-
def process_event(cls, event_data: dict) -> "IntegrationTrigger.Outputs":
|
42
|
+
def __init__(self, event_data: dict):
|
46
43
|
"""
|
47
|
-
|
44
|
+
Initialize trigger with event data from external system.
|
48
45
|
|
49
|
-
|
50
|
-
event payloads (e.g., Slack webhooks, email notifications)
|
51
|
-
|
46
|
+
Subclasses should override this method to parse external
|
47
|
+
event payloads (e.g., Slack webhooks, email notifications) and
|
48
|
+
populate trigger attributes.
|
52
49
|
|
53
50
|
Args:
|
54
51
|
event_data: Raw event data from the external system
|
55
52
|
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
53
|
+
Examples:
|
54
|
+
>>> class MyTrigger(IntegrationTrigger):
|
55
|
+
... data: str
|
56
|
+
...
|
57
|
+
... def __init__(self, event_data: dict):
|
58
|
+
... super().__init__(event_data)
|
59
|
+
... self.data = event_data.get("data", "")
|
60
|
+
>>>
|
61
|
+
>>> trigger = MyTrigger({"data": "hello"})
|
62
|
+
>>> state = workflow.get_default_state()
|
63
|
+
>>> trigger.bind_to_state(state)
|
64
|
+
>>> MyTrigger.data.resolve(state)
|
65
|
+
'hello'
|
61
66
|
"""
|
62
|
-
|
67
|
+
self._event_data = event_data
|
@@ -0,0 +1,101 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
from vellum.workflows.triggers.integration import IntegrationTrigger
|
4
|
+
|
5
|
+
|
6
|
+
class SlackTrigger(IntegrationTrigger):
|
7
|
+
"""
|
8
|
+
Trigger for Slack events (messages, mentions, reactions, etc.).
|
9
|
+
|
10
|
+
SlackTrigger enables workflows to be initiated from Slack events such as
|
11
|
+
messages in channels, direct messages, app mentions, or reactions. The trigger
|
12
|
+
parses Slack webhook payloads and provides structured attributes that downstream
|
13
|
+
nodes can reference.
|
14
|
+
|
15
|
+
Examples:
|
16
|
+
Basic Slack message trigger:
|
17
|
+
>>> class MyWorkflow(BaseWorkflow):
|
18
|
+
... graph = SlackTrigger >> ProcessMessageNode
|
19
|
+
|
20
|
+
Access trigger attributes in nodes:
|
21
|
+
>>> class ProcessMessageNode(BaseNode):
|
22
|
+
... class Outputs(BaseNode.Outputs):
|
23
|
+
... response = SlackTrigger.message
|
24
|
+
|
25
|
+
Multiple entry points:
|
26
|
+
>>> class MyWorkflow(BaseWorkflow):
|
27
|
+
... graph = {
|
28
|
+
... SlackTrigger >> ProcessSlackNode,
|
29
|
+
... ManualTrigger >> ProcessManualNode,
|
30
|
+
... }
|
31
|
+
|
32
|
+
Attributes:
|
33
|
+
message: str
|
34
|
+
The message text from Slack
|
35
|
+
channel: str
|
36
|
+
Slack channel ID where message was sent
|
37
|
+
user: str
|
38
|
+
User ID who sent the message
|
39
|
+
timestamp: str
|
40
|
+
Message timestamp (ts field from Slack)
|
41
|
+
thread_ts: Optional[str]
|
42
|
+
Thread timestamp if message is in a thread, None otherwise
|
43
|
+
event_type: str
|
44
|
+
Type of Slack event (e.g., "message", "app_mention")
|
45
|
+
|
46
|
+
Note:
|
47
|
+
The trigger expects Slack Event API webhook payloads. For details on
|
48
|
+
the payload structure, see: https://api.slack.com/apis/connections/events-api
|
49
|
+
"""
|
50
|
+
|
51
|
+
message: str
|
52
|
+
channel: str
|
53
|
+
user: str
|
54
|
+
timestamp: str
|
55
|
+
thread_ts: Optional[str]
|
56
|
+
event_type: str
|
57
|
+
|
58
|
+
def __init__(self, event_data: dict):
|
59
|
+
"""
|
60
|
+
Initialize SlackTrigger with Slack webhook payload.
|
61
|
+
|
62
|
+
Args:
|
63
|
+
event_data: Slack event payload from webhook. Expected structure:
|
64
|
+
{
|
65
|
+
"event": {
|
66
|
+
"type": "message",
|
67
|
+
"text": "Hello world",
|
68
|
+
"channel": "C123456",
|
69
|
+
"user": "U123456",
|
70
|
+
"ts": "1234567890.123456",
|
71
|
+
"thread_ts": "1234567890.123456" # optional
|
72
|
+
}
|
73
|
+
}
|
74
|
+
|
75
|
+
Examples:
|
76
|
+
>>> slack_payload = {
|
77
|
+
... "event": {
|
78
|
+
... "type": "message",
|
79
|
+
... "text": "Hello!",
|
80
|
+
... "channel": "C123",
|
81
|
+
... "user": "U456",
|
82
|
+
... "ts": "1234567890.123456"
|
83
|
+
... }
|
84
|
+
... }
|
85
|
+
>>> trigger = SlackTrigger(slack_payload)
|
86
|
+
>>> trigger.message
|
87
|
+
'Hello!'
|
88
|
+
"""
|
89
|
+
# Call parent init
|
90
|
+
super().__init__(event_data)
|
91
|
+
|
92
|
+
# Extract from Slack's event structure
|
93
|
+
event = event_data.get("event", {})
|
94
|
+
|
95
|
+
# Populate trigger attributes
|
96
|
+
self.message = event.get("text", "")
|
97
|
+
self.channel = event.get("channel", "")
|
98
|
+
self.user = event.get("user", "")
|
99
|
+
self.timestamp = event.get("ts", "")
|
100
|
+
self.thread_ts = event.get("thread_ts")
|
101
|
+
self.event_type = event.get("type", "message")
|
@@ -1,24 +1,29 @@
|
|
1
1
|
"""Tests for IntegrationTrigger base class."""
|
2
2
|
|
3
|
-
import pytest
|
4
|
-
|
5
3
|
from vellum.workflows.nodes.bases.base import BaseNode
|
4
|
+
from vellum.workflows.references.trigger import TriggerAttributeReference
|
5
|
+
from vellum.workflows.state.base import BaseState
|
6
6
|
from vellum.workflows.triggers.integration import IntegrationTrigger
|
7
7
|
|
8
8
|
|
9
|
-
def
|
10
|
-
"""IntegrationTrigger
|
11
|
-
# WHEN we
|
12
|
-
|
13
|
-
with pytest.raises(NotImplementedError, match="must implement process_event"):
|
14
|
-
IntegrationTrigger.process_event({})
|
9
|
+
def test_integration_trigger__can_be_instantiated_as_base():
|
10
|
+
"""IntegrationTrigger can be instantiated as a base class."""
|
11
|
+
# WHEN we instantiate IntegrationTrigger directly
|
12
|
+
trigger = IntegrationTrigger({"test": "data"})
|
15
13
|
|
14
|
+
# THEN it creates an instance with event data stored
|
15
|
+
assert trigger._event_data == {"test": "data"}
|
16
16
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
17
|
+
|
18
|
+
def test_integration_trigger__can_be_instantiated():
|
19
|
+
"""IntegrationTrigger can be instantiated for testing."""
|
20
|
+
|
21
|
+
# GIVEN IntegrationTrigger with concrete implementation
|
22
|
+
class TestTrigger(IntegrationTrigger):
|
23
|
+
pass
|
24
|
+
|
25
|
+
# THEN it can be instantiated (even though base is ABC, concrete subclasses work)
|
26
|
+
assert TestTrigger is not None
|
22
27
|
|
23
28
|
|
24
29
|
def test_integration_trigger__can_be_subclassed():
|
@@ -26,31 +31,51 @@ def test_integration_trigger__can_be_subclassed():
|
|
26
31
|
|
27
32
|
# GIVEN a concrete implementation of IntegrationTrigger
|
28
33
|
class TestTrigger(IntegrationTrigger):
|
29
|
-
|
30
|
-
data: str
|
34
|
+
data: str
|
31
35
|
|
32
|
-
|
33
|
-
|
34
|
-
|
36
|
+
def __init__(self, event_data: dict):
|
37
|
+
super().__init__(event_data)
|
38
|
+
self.data = event_data.get("data", "")
|
35
39
|
|
36
|
-
# WHEN we
|
37
|
-
result = TestTrigger
|
40
|
+
# WHEN we create a trigger instance
|
41
|
+
result = TestTrigger({"data": "test"})
|
38
42
|
|
39
|
-
# THEN it returns the expected
|
43
|
+
# THEN it returns the expected trigger instance with populated attributes
|
40
44
|
assert result.data == "test"
|
41
45
|
|
42
46
|
|
47
|
+
def test_integration_trigger__attribute_reference():
|
48
|
+
"""Trigger annotations expose TriggerAttributeReference descriptors."""
|
49
|
+
|
50
|
+
class TestTrigger(IntegrationTrigger):
|
51
|
+
value: str
|
52
|
+
|
53
|
+
def __init__(self, event_data: dict):
|
54
|
+
super().__init__(event_data)
|
55
|
+
self.value = event_data.get("value", "")
|
56
|
+
|
57
|
+
reference = TestTrigger.value
|
58
|
+
assert isinstance(reference, TriggerAttributeReference)
|
59
|
+
assert reference.name == "value"
|
60
|
+
assert TestTrigger.value is reference
|
61
|
+
assert reference is TestTrigger.attribute_references()["value"]
|
62
|
+
|
63
|
+
state = BaseState()
|
64
|
+
trigger = TestTrigger({"value": "data"})
|
65
|
+
trigger.bind_to_state(state)
|
66
|
+
assert reference.resolve(state) == "data"
|
67
|
+
|
68
|
+
|
43
69
|
def test_integration_trigger__graph_syntax():
|
44
70
|
"""IntegrationTrigger can be used in graph syntax."""
|
45
71
|
|
46
72
|
# GIVEN a concrete trigger and a node
|
47
73
|
class TestTrigger(IntegrationTrigger):
|
48
|
-
|
49
|
-
value: str
|
74
|
+
value: str
|
50
75
|
|
51
|
-
|
52
|
-
|
53
|
-
|
76
|
+
def __init__(self, event_data: dict):
|
77
|
+
super().__init__(event_data)
|
78
|
+
self.value = event_data.get("value", "")
|
54
79
|
|
55
80
|
class TestNode(BaseNode):
|
56
81
|
pass
|
@@ -70,12 +95,11 @@ def test_integration_trigger__multiple_entrypoints():
|
|
70
95
|
|
71
96
|
# GIVEN a trigger and multiple nodes
|
72
97
|
class TestTrigger(IntegrationTrigger):
|
73
|
-
|
74
|
-
msg: str
|
98
|
+
msg: str
|
75
99
|
|
76
|
-
|
77
|
-
|
78
|
-
|
100
|
+
def __init__(self, event_data: dict):
|
101
|
+
super().__init__(event_data)
|
102
|
+
self.msg = event_data.get("msg", "")
|
79
103
|
|
80
104
|
class NodeA(BaseNode):
|
81
105
|
pass
|
@@ -0,0 +1,180 @@
|
|
1
|
+
"""Tests for SlackTrigger."""
|
2
|
+
|
3
|
+
from typing import cast
|
4
|
+
|
5
|
+
from vellum.workflows.nodes.bases.base import BaseNode
|
6
|
+
from vellum.workflows.references.trigger import TriggerAttributeReference
|
7
|
+
from vellum.workflows.state.base import BaseState
|
8
|
+
from vellum.workflows.triggers.slack import SlackTrigger
|
9
|
+
|
10
|
+
|
11
|
+
def test_slack_trigger__basic():
|
12
|
+
"""SlackTrigger parses Slack payload correctly."""
|
13
|
+
# GIVEN a Slack event payload
|
14
|
+
slack_payload = {
|
15
|
+
"event": {
|
16
|
+
"type": "message",
|
17
|
+
"text": "Hello world!",
|
18
|
+
"channel": "C123456",
|
19
|
+
"user": "U123456",
|
20
|
+
"ts": "1234567890.123456",
|
21
|
+
}
|
22
|
+
}
|
23
|
+
|
24
|
+
# WHEN we create a trigger instance
|
25
|
+
trigger = SlackTrigger(slack_payload)
|
26
|
+
|
27
|
+
# THEN trigger attributes contain the correct data
|
28
|
+
assert trigger.message == "Hello world!"
|
29
|
+
assert trigger.channel == "C123456"
|
30
|
+
assert trigger.user == "U123456"
|
31
|
+
assert trigger.timestamp == "1234567890.123456"
|
32
|
+
assert trigger.thread_ts is None
|
33
|
+
assert trigger.event_type == "message"
|
34
|
+
|
35
|
+
# AND the trigger can bind values to workflow state
|
36
|
+
state = BaseState()
|
37
|
+
trigger.bind_to_state(state)
|
38
|
+
stored = state.meta.trigger_attributes
|
39
|
+
message_ref = cast(TriggerAttributeReference[str], SlackTrigger.message)
|
40
|
+
assert message_ref in stored
|
41
|
+
assert stored[message_ref] == "Hello world!"
|
42
|
+
|
43
|
+
|
44
|
+
def test_slack_trigger__with_thread():
|
45
|
+
"""SlackTrigger handles threaded messages."""
|
46
|
+
# GIVEN a Slack event payload with thread_ts
|
47
|
+
slack_payload = {
|
48
|
+
"event": {
|
49
|
+
"type": "message",
|
50
|
+
"text": "Reply in thread",
|
51
|
+
"channel": "C123456",
|
52
|
+
"user": "U123456",
|
53
|
+
"ts": "1234567891.123456",
|
54
|
+
"thread_ts": "1234567890.123456",
|
55
|
+
}
|
56
|
+
}
|
57
|
+
|
58
|
+
# WHEN we create a trigger instance
|
59
|
+
trigger = SlackTrigger(slack_payload)
|
60
|
+
|
61
|
+
# THEN thread_ts is populated
|
62
|
+
assert trigger.thread_ts == "1234567890.123456"
|
63
|
+
|
64
|
+
|
65
|
+
def test_slack_trigger__empty_payload():
|
66
|
+
"""SlackTrigger handles empty payload gracefully."""
|
67
|
+
# GIVEN an empty payload
|
68
|
+
slack_payload: dict[str, str] = {}
|
69
|
+
|
70
|
+
# WHEN we create a trigger instance
|
71
|
+
trigger = SlackTrigger(slack_payload)
|
72
|
+
|
73
|
+
# THEN it returns empty strings for required fields
|
74
|
+
assert trigger.message == ""
|
75
|
+
assert trigger.channel == ""
|
76
|
+
assert trigger.user == ""
|
77
|
+
assert trigger.timestamp == ""
|
78
|
+
assert trigger.thread_ts is None
|
79
|
+
assert trigger.event_type == "message" # default value
|
80
|
+
|
81
|
+
|
82
|
+
def test_slack_trigger__app_mention():
|
83
|
+
"""SlackTrigger handles app_mention events."""
|
84
|
+
# GIVEN an app_mention event
|
85
|
+
slack_payload = {
|
86
|
+
"event": {
|
87
|
+
"type": "app_mention",
|
88
|
+
"text": "<@U0LAN0Z89> is it everything a river should be?",
|
89
|
+
"channel": "C123456",
|
90
|
+
"user": "U123456",
|
91
|
+
"ts": "1234567890.123456",
|
92
|
+
}
|
93
|
+
}
|
94
|
+
|
95
|
+
# WHEN we create a trigger instance
|
96
|
+
trigger = SlackTrigger(slack_payload)
|
97
|
+
|
98
|
+
# THEN event_type is app_mention
|
99
|
+
assert trigger.event_type == "app_mention"
|
100
|
+
assert trigger.message == "<@U0LAN0Z89> is it everything a river should be?"
|
101
|
+
|
102
|
+
|
103
|
+
def test_slack_trigger__attributes():
|
104
|
+
"""SlackTrigger has correct attributes."""
|
105
|
+
# GIVEN SlackTrigger class
|
106
|
+
# THEN it exposes attribute references for annotated fields
|
107
|
+
reference = SlackTrigger.message
|
108
|
+
assert isinstance(reference, TriggerAttributeReference)
|
109
|
+
assert reference.name == "message"
|
110
|
+
assert SlackTrigger.message is reference # cache returns same reference
|
111
|
+
|
112
|
+
annotations = SlackTrigger.__annotations__
|
113
|
+
assert set(annotations) >= {"message", "channel", "user", "timestamp", "thread_ts", "event_type"}
|
114
|
+
|
115
|
+
# AND references resolve when present on state
|
116
|
+
state = BaseState()
|
117
|
+
state.meta.trigger_attributes[reference] = "Hello"
|
118
|
+
assert reference.resolve(state) == "Hello"
|
119
|
+
|
120
|
+
|
121
|
+
def test_slack_trigger__graph_syntax():
|
122
|
+
"""SlackTrigger can be used in graph syntax."""
|
123
|
+
|
124
|
+
# GIVEN a node
|
125
|
+
class TestNode(BaseNode):
|
126
|
+
pass
|
127
|
+
|
128
|
+
# WHEN we use SlackTrigger >> Node syntax
|
129
|
+
graph = SlackTrigger >> TestNode
|
130
|
+
|
131
|
+
# THEN a graph is created with trigger edge
|
132
|
+
assert graph is not None
|
133
|
+
trigger_edges = list(graph.trigger_edges)
|
134
|
+
assert len(trigger_edges) == 1
|
135
|
+
assert trigger_edges[0].trigger_class == SlackTrigger
|
136
|
+
assert trigger_edges[0].to_node == TestNode
|
137
|
+
|
138
|
+
|
139
|
+
def test_slack_trigger__multiple_entrypoints():
|
140
|
+
"""SlackTrigger works with multiple entry points."""
|
141
|
+
|
142
|
+
# GIVEN multiple nodes
|
143
|
+
class NodeA(BaseNode):
|
144
|
+
pass
|
145
|
+
|
146
|
+
class NodeB(BaseNode):
|
147
|
+
pass
|
148
|
+
|
149
|
+
# WHEN we use SlackTrigger >> {NodeA, NodeB} syntax
|
150
|
+
graph = SlackTrigger >> {NodeA, NodeB}
|
151
|
+
|
152
|
+
# THEN both nodes have trigger edges
|
153
|
+
trigger_edges = list(graph.trigger_edges)
|
154
|
+
assert len(trigger_edges) == 2
|
155
|
+
target_nodes = {edge.to_node for edge in trigger_edges}
|
156
|
+
assert target_nodes == {NodeA, NodeB}
|
157
|
+
|
158
|
+
|
159
|
+
def test_slack_trigger__trigger_then_graph():
|
160
|
+
"""SlackTrigger works with trigger >> node >> node syntax."""
|
161
|
+
|
162
|
+
# GIVEN two nodes
|
163
|
+
class StartNode(BaseNode):
|
164
|
+
pass
|
165
|
+
|
166
|
+
class EndNode(BaseNode):
|
167
|
+
pass
|
168
|
+
|
169
|
+
# WHEN we create SlackTrigger >> StartNode >> EndNode
|
170
|
+
graph = SlackTrigger >> StartNode >> EndNode
|
171
|
+
|
172
|
+
# THEN the graph has one trigger edge and one regular edge
|
173
|
+
trigger_edges = list(graph.trigger_edges)
|
174
|
+
assert len(trigger_edges) == 1
|
175
|
+
assert trigger_edges[0].trigger_class == SlackTrigger
|
176
|
+
assert trigger_edges[0].to_node == StartNode
|
177
|
+
|
178
|
+
edges = list(graph.edges)
|
179
|
+
assert len(edges) == 1
|
180
|
+
assert edges[0].to_node == EndNode
|