pyrtan / sourcev0.14.0 · main

pyrtan / tests / test_runtime.py

8,772 bytes · last changed 2026-10-25 in Read sentences with rules: rewrites, typed slots, synonyms, typos, pyrtan match

1import pytest
2
3from pyrtan import RecordedTransport, Registry, Runner
4from pyrtan.tree import load_tree_text
5
6ASKING = """
7name = "greeter"
8description = "asks for a name"
9start = "have-name"
10
11[inputs.name]
12type = "str"
13required = false
14
15[nodes.have-name]
16kind = "decide"
17on = "inputs.name"
18branches = [ { when = { exists = true }, then = "hello" }, { else = "ask" } ]
19
20[nodes.ask]
21kind = "ask"
22prompt = "What is your name?"
23into = "inputs.name"
24next = "hello"
25
26[nodes.hello]
27kind = "done"
28say = "Hello, {{ inputs.name }}."
29output = { name = "{{ inputs.name }}" }
30"""
31
32SUBFLOW = """
33name = "doubler"
34description = "doubles numbers with a local sub-flow"
35start = "walk"
36
37[inputs.numbers]
38type = "list"
39
40[nodes.walk]
41kind = "each"
42over = "inputs.numbers"
43limit = 3
44start = "double"
45into = "doubled"
46next = "reply"
47
48[nodes.double]
49kind = "set"
50set = { twice = "{{ item }}{{ item }}", at = "{{ index }}" }
51next = "give"
52
53[nodes.give]
54kind = "done"
55output = { n = "{{ item }}", twice = "{{ state.twice }}", at = "{{ state.at }}" }
56
57[nodes.reply]
58kind = "done"
59say = "{{ state.doubled | length }} doubled"
60output = "{{ state.doubled }}"
61"""
62
63GAPPY = """
64name = "gappy"
65description = "a decide with no else"
66hosts = ["x.test"]
67start = "get"
68
69[nodes.get]
70kind = "observe"
71http = { url = "https://x.test/" }
72into = "r"
73next = "pick"
74
75[nodes.pick]
76kind = "decide"
77on = "r.status"
78branches = [ { when = { eq = 200 }, then = "ok" } ]
79
80[nodes.ok]
81kind = "done"
82say = "fine"
83"""
84
85EMITTER = """
86name = "emitter"
87description = "acts by emitting"
88start = "fire"
89
90[inputs.what]
91type = "str"
92default = "ping"
93
94[nodes.fire]
95kind = "act"
96emit = { event = "pyrtan.{{ inputs.what }}", data = { at = "{{ now.year }}" } }
97into = "ev"
98next = "ok"
99
100[nodes.ok]
101kind = "done"
102say = "sent {{ state.ev.event }}"
103"""
104
105DELEGATOR = """
106name = "delegator"
107description = "delegates to the greeter and the doubler"
108start = "greet"
109
110[nodes.greet]
111kind = "delegate"
112tree = "greeter"
113with = { name = "Ada" }
114into = "greeting"
115next = "numbers"
116
117[nodes.numbers]
118kind = "delegate"
119intent = "double 1 2"
120into = "nums"
121next = "ok"
122
123[nodes.ok]
124kind = "done"
125say = "{{ state.greeting.say }} / {{ state.nums.status }}"
126"""
127
128RETRYING = """
129name = "retrying"
130description = "observe retries once, act does not"
131hosts = ["x.test"]
132start = "look"
133
134[nodes.look]
135kind = "observe"
136http = { url = "https://x.test/flaky" }
137into = "r"
138retries = 2
139next = "ok"
140
141[nodes.ok]
142kind = "done"
143say = "{{ state.r.status }}"
144"""
145
146
147def reg_with(*texts: str) -> Registry:
148 reg = Registry()
149 for t in texts:
150 reg.add(load_tree_text(t))
151 return reg
152
153
154def test_ask_and_resume():
155 runner = Runner(reg_with(ASKING), RecordedTransport())
156 out = runner.run_tree("greeter", {})
157 assert out.status == "asked" and out.question == "What is your name?"
158 assert out.snapshot["node"] == "ask"
159 done = runner.resume(out.snapshot, "Grace")
160 assert done.status == "done" and done.say == "Hello, Grace."
161 assert [s.node for s in done.trace.steps] == ["have-name", "ask", "ask", "hello"]
162 direct = runner.run_tree("greeter", {"name": "Ada"})
163 assert direct.say == "Hello, Ada."
164
165
166def test_each_with_local_subflow():
167 runner = Runner(reg_with(SUBFLOW), RecordedTransport())
168 out = runner.run_tree("doubler", {"numbers": [1, 2, 3, 4]})
169 assert out.status == "done"
170 assert out.output == [{"n": 1, "twice": "11", "at": 0}, {"n": 2, "twice": "22", "at": 1}, {"n": 3, "twice": "33", "at": 2}]
171 assert out.say == "3 doubled"
172 kinds = [(s.node, s.depth) for s in out.trace.steps]
173 assert ("double", 1) in kinds and ("walk", 0) in kinds
174
175
176def test_gap_is_recorded_and_fails_the_run():
177 rt = RecordedTransport()
178 rt.add("GET", "https://x.test/", 418, "")
179 out = Runner(reg_with(GAPPY), rt).run_tree("gappy")
180 assert out.status == "error" and out.reason == "gap"
181 assert out.trace.gaps == [{"tree": "gappy", "node": "pick", "on": "state.r.status", "observed": 418}]
182 assert "no branch" in out.trace.render()
183
184
185def test_host_enforced_at_run_time():
186 tree = load_tree_text(GAPPY.replace('http = { url = "https://x.test/" }', 'http = { url = "https://{{ inputs.h }}/" }') + '\n[inputs.h]\ntype = "str"\n')
187 reg = Registry()
188 reg.add(tree)
189 out = Runner(reg, RecordedTransport()).run_tree("gappy", {"h": "evil.test"})
190 assert out.status == "error" and out.reason == "host-not-allowed"
191
192
193def test_emit_is_collected():
194 runner = Runner(reg_with(EMITTER), RecordedTransport())
195 out = runner.run_tree("emitter", {"what": "pong"})
196 assert out.status == "done" and out.say == "sent pyrtan.pong"
197 assert runner.emitted[0]["event"] == "pyrtan.pong" and runner.emitted[0]["tree"] == "emitter"
198
199
200def test_delegate_by_tree_and_by_intent():
201 doubler = SUBFLOW + '\n[[triggers]]\npattern = "double {numbers}"\n'
202 doubler = doubler.replace('type = "list"', 'type = "str"').replace('over = "inputs.numbers"', 'over = "nums"').replace('start = "walk"', 'start = "split"') + """
203[nodes.split]
204kind = "set"
205set = { nums = ["{{ inputs.numbers | first }}", "{{ inputs.numbers | last }}"] }
206next = "walk"
207"""
208 runner = Runner(reg_with(ASKING, doubler, DELEGATOR), RecordedTransport())
209 out = runner.run_tree("delegator")
210 assert out.status == "done", out.say
211 assert out.say == "Hello, Ada. / done"
212 assert any(s.node == "hello" and s.depth == 1 for s in out.trace.steps)
213
214
215def test_delegate_to_unknown_tree_without_resolver():
216 runner = Runner(reg_with(DELEGATOR), RecordedTransport())
217 out = runner.run_tree("delegator")
218 assert out.status == "done"
219 assert out.say == "No tree named 'greeter'. / error" # the delegate's answer is captured; the run continues
220
221
222def test_observe_retries_but_act_does_not():
223 rt = RecordedTransport({"GET https://x.test/flaky": [{"error": "boom"}, {"error": "boom"}, {"status": 200, "body": "ok"}]})
224 out = Runner(reg_with(RETRYING), rt).run_tree("retrying")
225 assert out.status == "done" and out.say == "200"
226 assert len(rt.calls) == 3
227 rt = RecordedTransport({"GET https://x.test/flaky": [{"error": "boom"}, {"status": 200, "body": "ok"}]})
228 out = Runner(reg_with(RETRYING.replace("retries = 2", "retries = 0")), rt).run_tree("retrying")
229 assert out.status == "error" and out.reason == "transport"
230
231
232def test_no_match_and_ambiguity():
233 a = ASKING.replace('name = "greeter"', 'name = "a"') + '\n[[triggers]]\npattern = "hi {name}"\n'
234 b = ASKING.replace('name = "greeter"', 'name = "b"') + '\n[[triggers]]\npattern = "hi {name}"\n'
235 runner = Runner(reg_with(a, b), RecordedTransport())
236 assert runner.run_intent("hi there").reason == "ambiguous"
237 assert runner.run_intent("bye").reason == "no-match"
238
239
240def test_depth_limit():
241 text = """
242name = "recursive"
243description = "delegates to itself"
244start = "again"
245
246[nodes.again]
247kind = "delegate"
248tree = "recursive"
249into = "r"
250next = "ok"
251
252[nodes.ok]
253kind = "done"
254say = "{{ state.r.reason }}"
255"""
256 out = Runner(reg_with(text), RecordedTransport()).run_tree("recursive")
257 assert out.status == "done" and "too-deep" in out.trace.render()
258
259
260REMINDER = """
261name = "reminder"
262description = "reads a date and a time"
263start = "say"
264
265[language]
266synonyms = { remind = ["ping", "nudge"] }
267
268[[triggers]]
269pattern = "remind me {when:date} at {at:time}"
270
271[inputs.when]
272type = "str"
273[inputs.at]
274type = "str"
275
276[nodes.say]
277kind = "done"
278say = "{{ inputs.when }} {{ inputs.at }}"
279"""
280
281
282def test_trace_starts_with_how_the_sentence_was_read():
283 import datetime as dt
284
285 runner = Runner(reg_with(REMINDER), RecordedTransport(), clock=lambda: dt.datetime(2026, 10, 25, 12, tzinfo=dt.timezone.utc))
286 out = runner.run_intent("Hey Pyrtan, could you nudge me next friday at 3pm please")
287 assert out.status == "done" and out.say == "2026-10-30 15:00"
288 first = out.trace.steps[0]
289 assert first.kind == "intent" and first.node == "intent" and first.next == "say"
290 assert first.observed["cleaned"] == "nudge me next friday at 3pm"
291 assert first.observed["steps"] == ["fillers: 'Hey Pyrtan', 'please', 'could you'"] # in the order they were dropped
292 assert first.observed["inputs"] == {"when": "2026-10-30", "at": "15:00"}
293 assert "when: 'next friday' read as '2026-10-30'" in first.observed["notes"]
294 assert "reminder via 'remind me {when:date} at {at:time}'" in out.trace.render()
295 # The same clock drives {{ now }}: a run is deterministic given its observations.
296 out2 = runner.run_intent("remind me tomorrow at noon")
297 assert out2.say == "2026-10-26 12:00"
298 # A sentence that fits the shape but not the value is refused, and says why.
299 out3 = runner.run_intent("remind me someday at noon")
300 assert out3.reason == "no-match" and out3.output["reading"]["considered"] == ["remind me {when:date} at {at:time}"]