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
| 1 | import pytest |
| 2 | |
| 3 | from pyrtan import RecordedTransport, Registry, Runner |
| 4 | from pyrtan.tree import load_tree_text |
| 5 | |
| 6 | ASKING = """ |
| 7 | name = "greeter" |
| 8 | description = "asks for a name" |
| 9 | start = "have-name" |
| 10 | |
| 11 | [inputs.name] |
| 12 | type = "str" |
| 13 | required = false |
| 14 | |
| 15 | [nodes.have-name] |
| 16 | kind = "decide" |
| 17 | on = "inputs.name" |
| 18 | branches = [ { when = { exists = true }, then = "hello" }, { else = "ask" } ] |
| 19 | |
| 20 | [nodes.ask] |
| 21 | kind = "ask" |
| 22 | prompt = "What is your name?" |
| 23 | into = "inputs.name" |
| 24 | next = "hello" |
| 25 | |
| 26 | [nodes.hello] |
| 27 | kind = "done" |
| 28 | say = "Hello, {{ inputs.name }}." |
| 29 | output = { name = "{{ inputs.name }}" } |
| 30 | """ |
| 31 | |
| 32 | SUBFLOW = """ |
| 33 | name = "doubler" |
| 34 | description = "doubles numbers with a local sub-flow" |
| 35 | start = "walk" |
| 36 | |
| 37 | [inputs.numbers] |
| 38 | type = "list" |
| 39 | |
| 40 | [nodes.walk] |
| 41 | kind = "each" |
| 42 | over = "inputs.numbers" |
| 43 | limit = 3 |
| 44 | start = "double" |
| 45 | into = "doubled" |
| 46 | next = "reply" |
| 47 | |
| 48 | [nodes.double] |
| 49 | kind = "set" |
| 50 | set = { twice = "{{ item }}{{ item }}", at = "{{ index }}" } |
| 51 | next = "give" |
| 52 | |
| 53 | [nodes.give] |
| 54 | kind = "done" |
| 55 | output = { n = "{{ item }}", twice = "{{ state.twice }}", at = "{{ state.at }}" } |
| 56 | |
| 57 | [nodes.reply] |
| 58 | kind = "done" |
| 59 | say = "{{ state.doubled | length }} doubled" |
| 60 | output = "{{ state.doubled }}" |
| 61 | """ |
| 62 | |
| 63 | GAPPY = """ |
| 64 | name = "gappy" |
| 65 | description = "a decide with no else" |
| 66 | hosts = ["x.test"] |
| 67 | start = "get" |
| 68 | |
| 69 | [nodes.get] |
| 70 | kind = "observe" |
| 71 | http = { url = "https://x.test/" } |
| 72 | into = "r" |
| 73 | next = "pick" |
| 74 | |
| 75 | [nodes.pick] |
| 76 | kind = "decide" |
| 77 | on = "r.status" |
| 78 | branches = [ { when = { eq = 200 }, then = "ok" } ] |
| 79 | |
| 80 | [nodes.ok] |
| 81 | kind = "done" |
| 82 | say = "fine" |
| 83 | """ |
| 84 | |
| 85 | EMITTER = """ |
| 86 | name = "emitter" |
| 87 | description = "acts by emitting" |
| 88 | start = "fire" |
| 89 | |
| 90 | [inputs.what] |
| 91 | type = "str" |
| 92 | default = "ping" |
| 93 | |
| 94 | [nodes.fire] |
| 95 | kind = "act" |
| 96 | emit = { event = "pyrtan.{{ inputs.what }}", data = { at = "{{ now.year }}" } } |
| 97 | into = "ev" |
| 98 | next = "ok" |
| 99 | |
| 100 | [nodes.ok] |
| 101 | kind = "done" |
| 102 | say = "sent {{ state.ev.event }}" |
| 103 | """ |
| 104 | |
| 105 | DELEGATOR = """ |
| 106 | name = "delegator" |
| 107 | description = "delegates to the greeter and the doubler" |
| 108 | start = "greet" |
| 109 | |
| 110 | [nodes.greet] |
| 111 | kind = "delegate" |
| 112 | tree = "greeter" |
| 113 | with = { name = "Ada" } |
| 114 | into = "greeting" |
| 115 | next = "numbers" |
| 116 | |
| 117 | [nodes.numbers] |
| 118 | kind = "delegate" |
| 119 | intent = "double 1 2" |
| 120 | into = "nums" |
| 121 | next = "ok" |
| 122 | |
| 123 | [nodes.ok] |
| 124 | kind = "done" |
| 125 | say = "{{ state.greeting.say }} / {{ state.nums.status }}" |
| 126 | """ |
| 127 | |
| 128 | RETRYING = """ |
| 129 | name = "retrying" |
| 130 | description = "observe retries once, act does not" |
| 131 | hosts = ["x.test"] |
| 132 | start = "look" |
| 133 | |
| 134 | [nodes.look] |
| 135 | kind = "observe" |
| 136 | http = { url = "https://x.test/flaky" } |
| 137 | into = "r" |
| 138 | retries = 2 |
| 139 | next = "ok" |
| 140 | |
| 141 | [nodes.ok] |
| 142 | kind = "done" |
| 143 | say = "{{ state.r.status }}" |
| 144 | """ |
| 145 | |
| 146 | |
| 147 | def 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 | |
| 154 | def 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 | |
| 166 | def 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 | |
| 176 | def 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 | |
| 185 | def 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 | |
| 193 | def 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 | |
| 200 | def 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] |
| 204 | kind = "set" |
| 205 | set = { nums = ["{{ inputs.numbers | first }}", "{{ inputs.numbers | last }}"] } |
| 206 | next = "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 | |
| 215 | def 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 | |
| 222 | def 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 | |
| 232 | def 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 | |
| 240 | def test_depth_limit(): |
| 241 | text = """ |
| 242 | name = "recursive" |
| 243 | description = "delegates to itself" |
| 244 | start = "again" |
| 245 | |
| 246 | [nodes.again] |
| 247 | kind = "delegate" |
| 248 | tree = "recursive" |
| 249 | into = "r" |
| 250 | next = "ok" |
| 251 | |
| 252 | [nodes.ok] |
| 253 | kind = "done" |
| 254 | say = "{{ 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 | |
| 260 | REMINDER = """ |
| 261 | name = "reminder" |
| 262 | description = "reads a date and a time" |
| 263 | start = "say" |
| 264 | |
| 265 | [language] |
| 266 | synonyms = { remind = ["ping", "nudge"] } |
| 267 | |
| 268 | [[triggers]] |
| 269 | pattern = "remind me {when:date} at {at:time}" |
| 270 | |
| 271 | [inputs.when] |
| 272 | type = "str" |
| 273 | [inputs.at] |
| 274 | type = "str" |
| 275 | |
| 276 | [nodes.say] |
| 277 | kind = "done" |
| 278 | say = "{{ inputs.when }} {{ inputs.at }}" |
| 279 | """ |
| 280 | |
| 281 | |
| 282 | def 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}"] |