flowproof 0.6.0__tar.gz → 0.7.0__tar.gz

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.
Files changed (95) hide show
  1. {flowproof-0.6.0 → flowproof-0.7.0}/Cargo.lock +7 -7
  2. {flowproof-0.6.0 → flowproof-0.7.0}/Cargo.toml +1 -1
  3. {flowproof-0.6.0 → flowproof-0.7.0}/PKG-INFO +8 -5
  4. {flowproof-0.6.0 → flowproof-0.7.0}/README.md +6 -3
  5. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/agent_runner.rs +114 -1
  6. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/src/agent_flow.rs +2 -1
  7. flowproof-0.7.0/crates/flowproof-cli/tests/examples_resolve.rs +191 -0
  8. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/cassette.rs +89 -3
  9. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/cassette_diff.rs +4 -1
  10. {flowproof-0.6.0 → flowproof-0.7.0}/flowproof/__init__.py +1 -1
  11. {flowproof-0.6.0 → flowproof-0.7.0}/pyproject.toml +2 -2
  12. flowproof-0.6.0/crates/flowproof-cli/tests/examples_resolve.rs +0 -72
  13. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/Cargo.toml +0 -0
  14. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/agent_proxy.rs +0 -0
  15. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/egress.rs +0 -0
  16. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/egress_linux.rs +0 -0
  17. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/lib.rs +0 -0
  18. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/mcp_core.rs +0 -0
  19. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/mcp_http.rs +0 -0
  20. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/mcp_stdio.rs +0 -0
  21. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/sap_com.rs +0 -0
  22. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/vision.rs +0 -0
  23. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-adapters/src/web.rs +0 -0
  24. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/Cargo.toml +0 -0
  25. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/agent_steps.rs +0 -0
  26. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/author.rs +0 -0
  27. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/clarify.rs +0 -0
  28. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/heal.rs +0 -0
  29. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/lib.rs +0 -0
  30. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/llm.rs +0 -0
  31. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/recorder.rs +0 -0
  32. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/rules.rs +0 -0
  33. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-agent/src/spec.rs +0 -0
  34. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/Cargo.toml +0 -0
  35. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/src/capture.rs +0 -0
  36. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/src/lib.rs +0 -0
  37. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/src/main.rs +0 -0
  38. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/agent_flow_e2e.rs +0 -0
  39. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/api_pipeline.rs +0 -0
  40. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/audit_record_e2e.rs +0 -0
  41. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/calc_e2e.rs +0 -0
  42. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/clock_e2e.rs +0 -0
  43. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/cookie_e2e.rs +0 -0
  44. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/egress_e2e.rs +0 -0
  45. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/grid_cell_e2e.rs +0 -0
  46. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/iframe_e2e.rs +0 -0
  47. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/llm_author_e2e.rs +0 -0
  48. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/mcp_stdio_e2e.rs +0 -0
  49. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/notepad_author_e2e.rs +0 -0
  50. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/notepad_e2e.rs +0 -0
  51. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/page_title_e2e.rs +0 -0
  52. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/sap_e2e.rs +0 -0
  53. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/sap_pipeline.rs +0 -0
  54. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/sap_sim_e2e.rs +0 -0
  55. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/scoped_container_e2e.rs +0 -0
  56. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/skip_unless_env.rs +0 -0
  57. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/suite_env_from.rs +0 -0
  58. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/suite_flow_isolation.rs +0 -0
  59. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/suite_missing_trace.rs +0 -0
  60. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/support/sap_simulator.py +0 -0
  61. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/vision_pipeline.rs +0 -0
  62. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-cli/tests/web_e2e.rs +0 -0
  63. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/Cargo.toml +0 -0
  64. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/app.rs +0 -0
  65. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/backend.rs +0 -0
  66. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/gdi.rs +0 -0
  67. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/lib.rs +0 -0
  68. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/mock.rs +0 -0
  69. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/oob.rs +0 -0
  70. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/recording.rs +0 -0
  71. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/redact.rs +0 -0
  72. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/visual.rs +0 -0
  73. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-driver/src/window.rs +0 -0
  74. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-python/Cargo.toml +0 -0
  75. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-python/src/lib.rs +0 -0
  76. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-replay/Cargo.toml +0 -0
  77. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-replay/src/lib.rs +0 -0
  78. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-replay/src/report.rs +0 -0
  79. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-replay/src/runrecord.rs +0 -0
  80. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-replay/tests/replay_calc.rs +0 -0
  81. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/Cargo.toml +0 -0
  82. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/schema/trace-v1.schema.json +0 -0
  83. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/egress.rs +0 -0
  84. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/format.rs +0 -0
  85. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/lib.rs +0 -0
  86. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/secret.rs +0 -0
  87. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/secret_scan.rs +0 -0
  88. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/substitution.rs +0 -0
  89. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/src/toolcalls.rs +0 -0
  90. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/tests/fixtures/sample.trace.jsonl +0 -0
  91. {flowproof-0.6.0 → flowproof-0.7.0}/crates/flowproof-trace/tests/schema_conformance.rs +0 -0
  92. {flowproof-0.6.0 → flowproof-0.7.0}/flowproof/cli.py +0 -0
  93. {flowproof-0.6.0 → flowproof-0.7.0}/flowproof/flow.py +0 -0
  94. {flowproof-0.6.0 → flowproof-0.7.0}/flowproof/mcp_server.py +0 -0
  95. {flowproof-0.6.0 → flowproof-0.7.0}/flowproof/py.typed +0 -0
@@ -760,7 +760,7 @@ dependencies = [
760
760
 
761
761
  [[package]]
762
762
  name = "flowproof-adapters"
763
- version = "0.6.0"
763
+ version = "0.7.0"
764
764
  dependencies = [
765
765
  "anyhow",
766
766
  "flowproof-driver",
@@ -779,7 +779,7 @@ dependencies = [
779
779
 
780
780
  [[package]]
781
781
  name = "flowproof-agent"
782
- version = "0.6.0"
782
+ version = "0.7.0"
783
783
  dependencies = [
784
784
  "chrono",
785
785
  "flowproof-driver",
@@ -795,7 +795,7 @@ dependencies = [
795
795
 
796
796
  [[package]]
797
797
  name = "flowproof-cli"
798
- version = "0.6.0"
798
+ version = "0.7.0"
799
799
  dependencies = [
800
800
  "ab_glyph",
801
801
  "chrono",
@@ -816,7 +816,7 @@ dependencies = [
816
816
 
817
817
  [[package]]
818
818
  name = "flowproof-driver"
819
- version = "0.6.0"
819
+ version = "0.7.0"
820
820
  dependencies = [
821
821
  "image",
822
822
  "postgres",
@@ -830,7 +830,7 @@ dependencies = [
830
830
 
831
831
  [[package]]
832
832
  name = "flowproof-python"
833
- version = "0.6.0"
833
+ version = "0.7.0"
834
834
  dependencies = [
835
835
  "flowproof-agent",
836
836
  "flowproof-cli",
@@ -842,7 +842,7 @@ dependencies = [
842
842
 
843
843
  [[package]]
844
844
  name = "flowproof-replay"
845
- version = "0.6.0"
845
+ version = "0.7.0"
846
846
  dependencies = [
847
847
  "chrono",
848
848
  "flowproof-agent",
@@ -856,7 +856,7 @@ dependencies = [
856
856
 
857
857
  [[package]]
858
858
  name = "flowproof-trace"
859
- version = "0.6.0"
859
+ version = "0.7.0"
860
860
  dependencies = [
861
861
  "jsonschema",
862
862
  "regex",
@@ -3,7 +3,7 @@ resolver = "2"
3
3
  members = ["crates/flowproof-driver", "crates/flowproof-trace", "crates/flowproof-replay", "crates/flowproof-agent", "crates/flowproof-adapters", "crates/flowproof-cli", "crates/flowproof-python"]
4
4
 
5
5
  [workspace.package]
6
- version = "0.6.0"
6
+ version = "0.7.0"
7
7
  edition = "2021"
8
8
  license = "Apache-2.0"
9
9
  repository = "https://github.com/automators-com/flowproof"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flowproof
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Classifier: Development Status :: 2 - Pre-Alpha
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Programming Language :: Python :: 3
@@ -8,7 +8,7 @@ Classifier: Programming Language :: Rust
8
8
  Classifier: Topic :: Software Development :: Testing
9
9
  Requires-Dist: mcp>=1.2 ; python_full_version >= '3.10' and extra == 'mcp'
10
10
  Provides-Extra: mcp
11
- Summary: Generic open-source automation framework for the AI-agent era: automated testing and agentic process automation across web, desktop, and Citrix.
11
+ Summary: Deterministic tests for AI agents: record a run once, replay it with zero LLM calls. Assert which tools an agent called, with which arguments, in which order.
12
12
  Author-email: Automators <hello@automators.com>
13
13
  License-Expression: Apache-2.0
14
14
  Requires-Python: >=3.9
@@ -18,9 +18,12 @@ Project-URL: Repository, https://github.com/automators-com/flowproof
18
18
 
19
19
  # flowproof
20
20
 
21
- A generic open-source automation framework for the AI-agent era:
22
- automated testing and agentic process automation across web, desktop,
23
- and Citrix.
21
+ Run your AI agent once, keep the recording, and assert against it from then
22
+ on. flowproof captures the run at the model boundary - every request and
23
+ every tool-call decision - and serves it back on later runs, so replay makes
24
+ **zero LLM calls**. You assert which tools were called, with which arguments,
25
+ in which order, and which were not. The same engine drives web, desktop and
26
+ Citrix.
24
27
 
25
28
  **Agents author, a deterministic engine executes.** A flow is described in
26
29
  YAML with natural-language steps, recorded once against the live app, and
@@ -1,8 +1,11 @@
1
1
  # flowproof
2
2
 
3
- A generic open-source automation framework for the AI-agent era:
4
- automated testing and agentic process automation across web, desktop,
5
- and Citrix.
3
+ Run your AI agent once, keep the recording, and assert against it from then
4
+ on. flowproof captures the run at the model boundary - every request and
5
+ every tool-call decision - and serves it back on later runs, so replay makes
6
+ **zero LLM calls**. You assert which tools were called, with which arguments,
7
+ in which order, and which were not. The same engine drives web, desktop and
8
+ Citrix.
6
9
 
7
10
  **Agents author, a deterministic engine executes.** A flow is described in
8
11
  YAML with natural-language steps, recorded once against the live app, and
@@ -277,12 +277,54 @@ fn configure(
277
277
  child.env("FLOWPROOF_LLM_PROXY", base);
278
278
  // The spec's own env goes on LAST so a flow can override any of the
279
279
  // above; it knows its client better than this module does.
280
+ //
281
+ // Values may reference runtime handles the spec cannot know when it is
282
+ // written, because the proxy binds an ephemeral port. Without this, an
283
+ // agent whose client reads a non-standard variable (an AI gateway URL,
284
+ // say) cannot be pointed at the proxy from the spec at all, and every
285
+ // such adopter writes the same wrapper script.
280
286
  for (key, value) in env {
281
- child.env(key, value);
287
+ child.env(key, substitute_runtime_handles(value, base, env));
282
288
  }
283
289
  Ok(child)
284
290
  }
285
291
 
292
+ /// Replace the `${flowproof.*}` handles with values only known at spawn time.
293
+ ///
294
+ /// - `${flowproof.proxy_url}` - the model proxy, `/v1` included, the form
295
+ /// OpenAI-compatible clients expect.
296
+ /// - `${flowproof.proxy_url_no_v1}` - the same origin without `/v1`, for a
297
+ /// client that appends its own path (the Anthropic SDK does).
298
+ /// - `${flowproof.mcp_url.<name>}` - the stand-in URL for that MCP server,
299
+ /// for a client that builds its endpoints from a base rather than taking
300
+ /// one URL per server.
301
+ ///
302
+ /// Anything else is left untouched: an unknown handle is far more likely to
303
+ /// be a value that happens to look like one than a typo worth failing on,
304
+ /// and `${VAR}` secret refs have already been resolved by this point.
305
+ fn substitute_runtime_handles(value: &str, base: &str, env: &BTreeMap<String, String>) -> String {
306
+ if !value.contains("${flowproof.") {
307
+ return value.to_string();
308
+ }
309
+ let mut out = value
310
+ .replace("${flowproof.proxy_url_no_v1}", &anthropic_base(base))
311
+ .replace("${flowproof.proxy_url}", base);
312
+ // The MCP stand-in URLs are already in this same map, injected as
313
+ // `FLOWPROOF_MCP_URL_<NAME>`; this is a friendlier spelling of the same
314
+ // value, so the two can never disagree.
315
+ for (key, url) in env {
316
+ if let Some(name) = key.strip_prefix("FLOWPROOF_MCP_URL_") {
317
+ out = out.replace(&format!("${{flowproof.mcp_url.{name}}}"), url);
318
+ // Server names are lowercase in the spec; the env var is upper.
319
+ out = out.replace(
320
+ &format!("${{flowproof.mcp_url.{}}}", name.to_ascii_lowercase()),
321
+ url,
322
+ );
323
+ }
324
+ }
325
+ out
326
+ }
327
+
286
328
  /// Wait for `child` to the deadline, killing it at the timeout. Returns the
287
329
  /// exit status (`None` if killed or unwaitable) and whether it timed out.
288
330
  fn wait_to_deadline(
@@ -434,6 +476,77 @@ pub fn run_against_contained(
434
476
 
435
477
  #[cfg(test)]
436
478
  mod tests {
479
+
480
+ /// The gap a real adopter hit: their client reads AI_GATEWAY_URL, and
481
+ /// the proxy's port is not known when the spec is written, so a static
482
+ /// `agent.env` value could not reach it.
483
+ #[test]
484
+ fn the_proxy_url_is_addressable_from_a_spec_env_value() {
485
+ let env = BTreeMap::new();
486
+ let got =
487
+ substitute_runtime_handles("${flowproof.proxy_url}", "http://127.0.0.1:51234/v1", &env);
488
+ assert_eq!(got, "http://127.0.0.1:51234/v1");
489
+ }
490
+
491
+ /// Some clients append their own `/v1`, so handing them the suffixed
492
+ /// form produces `/v1/v1`. The no-suffix handle is for those.
493
+ #[test]
494
+ fn the_no_v1_handle_strips_the_suffix() {
495
+ let env = BTreeMap::new();
496
+ let got = substitute_runtime_handles(
497
+ "${flowproof.proxy_url_no_v1}",
498
+ "http://127.0.0.1:51234/v1",
499
+ &env,
500
+ );
501
+ assert_eq!(got, "http://127.0.0.1:51234");
502
+ }
503
+
504
+ /// A client that builds several endpoints off one base (`<base>/mcp`,
505
+ /// `<base>/mcp-exec/sap`) has no per-server variable to override, so it
506
+ /// needs the stand-in's base by name.
507
+ #[test]
508
+ fn an_mcp_stand_in_is_addressable_by_server_name() {
509
+ let mut env = BTreeMap::new();
510
+ env.insert(
511
+ "FLOWPROOF_MCP_URL_DATAMAKER_EXEC".to_string(),
512
+ "http://127.0.0.1:44100/mcp".to_string(),
513
+ );
514
+ // Upper (as the var is spelled) and lower (as the spec names it).
515
+ for handle in [
516
+ "${flowproof.mcp_url.DATAMAKER_EXEC}",
517
+ "${flowproof.mcp_url.datamaker_exec}",
518
+ ] {
519
+ let got = substitute_runtime_handles(handle, "http://127.0.0.1:1/v1", &env);
520
+ assert_eq!(got, "http://127.0.0.1:44100/mcp", "{handle}");
521
+ }
522
+ }
523
+
524
+ /// Handles interpolate INTO a larger value, because that is how a base
525
+ /// URL is actually used.
526
+ #[test]
527
+ fn a_handle_substitutes_inside_a_longer_value() {
528
+ let env = BTreeMap::new();
529
+ let got = substitute_runtime_handles(
530
+ "${flowproof.proxy_url_no_v1}/custom/path",
531
+ "http://127.0.0.1:9/v1",
532
+ &env,
533
+ );
534
+ assert_eq!(got, "http://127.0.0.1:9/custom/path");
535
+ }
536
+
537
+ /// A value that is not a handle must survive untouched - including one
538
+ /// that merely looks shell-ish. Failing on an unknown handle would be
539
+ /// worse than passing it through.
540
+ #[test]
541
+ fn ordinary_values_and_unknown_handles_pass_through() {
542
+ let env = BTreeMap::new();
543
+ for value in ["plain", "$HOME/x", "${flowproof.not_a_handle}"] {
544
+ assert_eq!(
545
+ substitute_runtime_handles(value, "http://127.0.0.1:1/v1", &env),
546
+ value
547
+ );
548
+ }
549
+ }
437
550
  use super::*;
438
551
  use flowproof_trace::cassette::{Message, ToolCall, Turn, TurnRequest, TurnResponse};
439
552
 
@@ -1007,7 +1007,8 @@ fn unprotected_tool_warning(spec: &FlowSpec, plan: &Plan, phase: &str) -> Option
1007
1007
  intercept tool execution - {consequence}.\n \
1008
1008
  For a tool with real side effects, declare it under `mcp:` with a `result:` \
1009
1009
  (flowproof answers it and the real server never runs it), or stub/sandbox it \
1010
- author-side. See docs/agent-testing.md."
1010
+ author-side. See \
1011
+ https://github.com/automators-com/flowproof/blob/main/docs/agent-testing.md."
1011
1012
  ))
1012
1013
  }
1013
1014
 
@@ -0,0 +1,191 @@
1
+ //! Shipped examples must stay honest: every step parses and resolves
2
+ //! through the deterministic rules — no live system needed, no model
3
+ //! backend. This is the same role `documented_grammar_examples_all_
4
+ //! resolve` plays for docs/authoring.md.
5
+
6
+ use flowproof_agent::{FlowSpec, SuiteManifest};
7
+
8
+ const FIORI_SPEC: &str = include_str!("../../../examples/fiori/manage-info-records.flow.yaml");
9
+ const FIORI_SUITE: &str = include_str!("../../../examples/fiori/suite.yaml");
10
+ const CONN_TEST_SPEC: &str = include_str!("../../../examples/api/connection-test.flow.yaml");
11
+ /// The npm-path agent quickstart. It is the first example a reader coming
12
+ /// from `npx flowproof` runs, so it has to keep parsing.
13
+ const AGENT_NODE_SPEC: &str = include_str!("../../../examples/agent-demo/weather-node.flow.yaml");
14
+ const AGENT_PY_SPEC: &str = include_str!("../../../examples/agent-demo/weather.flow.yaml");
15
+ const DEMO_SPEC: &str = include_str!("../../../scripts/demo/order-status.flow.yaml");
16
+
17
+ #[test]
18
+ fn connection_test_example_resolves_with_body_and_headers() {
19
+ let spec = FlowSpec::parse(CONN_TEST_SPEC).expect("example parses");
20
+ assert_eq!(spec.app.id(), "api");
21
+ let actions =
22
+ flowproof_agent::rules::resolve_step(spec.app.id(), &spec.steps[0]).expect("step resolves");
23
+ let flowproof_agent::rules::ResolvedAction::AssertApi {
24
+ headers,
25
+ body,
26
+ status,
27
+ ..
28
+ } = &actions[0]
29
+ else {
30
+ panic!("expected AssertApi");
31
+ };
32
+ // Raw refs in the resolved action — resolution is probe-time only.
33
+ assert_eq!(
34
+ headers.get("Authorization").map(String::as_str),
35
+ Some("Bearer ${DM_SESSION_TOKEN}")
36
+ );
37
+ let body = body.as_ref().expect("body present");
38
+ assert_eq!(body["connectionString"], "${TEST_CONN_STRING}");
39
+ // Pinned to the real DataMaker /connections/test contract the example
40
+ // mirrors (the api_pipeline mock speaks the same shape): the field is
41
+ // `type`, and an unsupported provider answers 500. Drift fails here.
42
+ assert_eq!(body["type"], "postgres");
43
+ assert_eq!(*status, Some(500));
44
+ }
45
+
46
+ #[test]
47
+ fn fiori_example_resolves_entirely_via_rules() {
48
+ let spec = FlowSpec::parse(FIORI_SPEC).expect("example parses");
49
+ assert_eq!(spec.app.id(), "web", "Fiori is a browser app");
50
+ assert!(
51
+ spec.url
52
+ .as_deref()
53
+ .unwrap_or_default()
54
+ .contains("${FIORI_BASE_URL}"),
55
+ "launch URL stays a ${{VAR}} reference"
56
+ );
57
+ for step in &spec.steps {
58
+ let actions = flowproof_agent::rules::resolve_step(spec.app.id(), step)
59
+ .unwrap_or_else(|e| panic!("step '{}' must resolve via rules: {e}", step.intent()));
60
+ assert!(
61
+ !actions.is_empty(),
62
+ "step '{}' yields actions",
63
+ step.intent()
64
+ );
65
+ }
66
+ }
67
+
68
+ #[test]
69
+ fn fiori_suite_manifest_declares_the_data_leg() {
70
+ let manifest: SuiteManifest = serde_yaml::from_str(FIORI_SUITE).expect("suite.yaml parses");
71
+ let cmd = manifest.env_from.expect("env_from present");
72
+ assert!(
73
+ cmd.contains("datamaker"),
74
+ "data comes from the DataMaker CLI"
75
+ );
76
+ assert!(manifest.env.contains_key("FIORI_BASE_URL"));
77
+ }
78
+
79
+ /// The quickstart's two agent demos must stay runnable and stay TWINS: the
80
+ /// docs present them as the same flow in two languages, so a change to one
81
+ /// that is not mirrored in the other makes the documentation lie.
82
+ #[test]
83
+ fn both_agent_demos_resolve_and_assert_the_same_thing() {
84
+ let node = FlowSpec::parse(AGENT_NODE_SPEC).expect("node example parses");
85
+ let py = FlowSpec::parse(AGENT_PY_SPEC).expect("python example parses");
86
+
87
+ for spec in [&node, &py] {
88
+ assert_eq!(spec.app.id(), "agent");
89
+ // A mocked tool is what makes replay deterministic here; without a
90
+ // result the demo would depend on a live timestamp.
91
+ let mocked = spec
92
+ .tools
93
+ .iter()
94
+ .find(|t| t.name == "get_weather")
95
+ .expect("get_weather declared");
96
+ assert!(!mocked.result.is_null(), "get_weather must carry a result:");
97
+ }
98
+
99
+ // Same trajectory, same assertions - only the runtime differs.
100
+ assert_eq!(
101
+ node.steps.len(),
102
+ py.steps.len(),
103
+ "the two demos must stay the same flow"
104
+ );
105
+
106
+ // The Node demo must not need Python, which is the entire reason it
107
+ // exists: the npm install path has to stand on its own.
108
+ let command = node
109
+ .agent
110
+ .as_ref()
111
+ .and_then(|a| a.command.clone())
112
+ .expect("node demo has a command");
113
+ assert!(
114
+ command.starts_with("node "),
115
+ "the npm-path demo must run under node, got: {command}"
116
+ );
117
+ assert!(
118
+ !command.contains("python"),
119
+ "the npm-path demo must not need Python: {command}"
120
+ );
121
+ }
122
+
123
+ /// The two quickstarts are the most-read prose we have and they are now the
124
+ /// front door for the npm audience, so their YAML must not drift from the file
125
+ /// they claim to quote. A reader who copies a block that no longer parses is
126
+ /// the worst possible first experience. Both the README and
127
+ /// docs/getting-started.md open on the same shipped example, so both are held
128
+ /// to it here.
129
+ #[test]
130
+ fn the_quickstart_quotes_the_shipped_agent_example_verbatim() {
131
+ const README: &str = include_str!("../../../README.md");
132
+ const DOC: &str = include_str!("../../../docs/getting-started.md");
133
+
134
+ // The shipped file, minus its comment header.
135
+ let shipped: String = AGENT_NODE_SPEC
136
+ .lines()
137
+ .filter(|l| !l.starts_with('#'))
138
+ .collect::<Vec<_>>()
139
+ .join("\n");
140
+
141
+ for (name, prose) in [("README.md", README), ("docs/getting-started.md", DOC)] {
142
+ // Pull the fenced block that names the example file.
143
+ let marker = "```yaml\n# examples/agent-demo/weather-node.flow.yaml\n";
144
+ let start = prose
145
+ .find(marker)
146
+ .unwrap_or_else(|| panic!("{name} quotes the node example"))
147
+ + marker.len();
148
+ let block = &prose[start..];
149
+ let block = &block[..block.find("```").expect("fence closes")];
150
+
151
+ assert_eq!(
152
+ block.trim(),
153
+ shipped.trim(),
154
+ "{name} has drifted from examples/agent-demo/weather-node.flow.yaml"
155
+ );
156
+
157
+ // And it must still parse, so the block a reader copies actually runs.
158
+ FlowSpec::parse(block).expect("the quoted quickstart block parses");
159
+ }
160
+ }
161
+
162
+ /// The flow behind the README's demo GIF. The GIF is a capture of this spec
163
+ /// running, so a grammar change that breaks it would silently make the README's
164
+ /// hero image a picture of something that no longer works.
165
+ #[test]
166
+ fn readme_demo_spec_resolves() {
167
+ let spec = FlowSpec::parse(DEMO_SPEC).expect("demo spec parses");
168
+ assert_eq!(spec.app.id(), "agent");
169
+
170
+ // Deliberately unmocked: the demo's tool returns a deterministic result, so
171
+ // replay needs no substitution and the run earns no unprotected-tool
172
+ // warning. That is what keeps the captured output to five lines.
173
+ let tool = spec
174
+ .tools
175
+ .iter()
176
+ .find(|t| t.name == "lookup_order")
177
+ .expect("lookup_order declared");
178
+ assert!(
179
+ tool.result.is_null(),
180
+ "the demo tool must stay declaration-only"
181
+ );
182
+
183
+ // Paths are relative to the repository root, because that is where the GIF
184
+ // runs its commands and therefore what a reader copies.
185
+ let command = spec
186
+ .agent
187
+ .as_ref()
188
+ .and_then(|a| a.command.clone())
189
+ .expect("demo has a command");
190
+ assert_eq!(command, "python3 scripts/demo/support_agent.py");
191
+ }
@@ -203,11 +203,35 @@ fn message_divergence(recorded: &[Message], incoming: &[Message]) -> Option<Stri
203
203
  .collect::<Vec<_>>()
204
204
  .join(", ")
205
205
  };
206
+ // When the SAME tools were called and only their arguments
207
+ // moved, the two name lists are identical and printing them
208
+ // says nothing at all - the reader is told something changed
209
+ // and left to find it. Name the argument PATH instead.
210
+ let (want_names, got_names) = (names(&want.tool_calls), names(&got.tool_calls));
211
+ if want_names == got_names {
212
+ let changes: Vec<String> = want
213
+ .tool_calls
214
+ .iter()
215
+ .zip(&got.tool_calls)
216
+ .flat_map(|(a, b)| {
217
+ crate::cassette_diff::argument_changes(a, b)
218
+ .into_iter()
219
+ .map(move |(path, before, after)| {
220
+ format!("{}.{path}: recorded {before}, replayed {after}", a.name)
221
+ })
222
+ })
223
+ .collect();
224
+ if !changes.is_empty() {
225
+ return Some(format!(
226
+ "message {i} ({}) tool call arguments changed\n {}",
227
+ want.role,
228
+ changes.join("\n "),
229
+ ));
230
+ }
231
+ }
206
232
  return Some(format!(
207
233
  "message {i} ({}) tool calls changed\n recorded: [{}]\n replayed: [{}]",
208
- want.role,
209
- names(&want.tool_calls),
210
- names(&got.tool_calls),
234
+ want.role, want_names, got_names,
211
235
  ));
212
236
  }
213
237
  return Some(format!("message {i} ({}) changed", want.role));
@@ -380,6 +404,68 @@ mod tests {
380
404
  }
381
405
  }
382
406
 
407
+ /// An argument-only change used to print two IDENTICAL tool-name lists
408
+ /// ("recorded: [book] / replayed: [book]"), telling the reader that
409
+ /// something moved but not what. It names the PATH now.
410
+ #[test]
411
+ fn an_argument_only_change_names_the_path_that_moved() {
412
+ let recorded = assistant_with(vec![call("book", r#"{"flight":{"id":"KQ311"},"seats":2}"#)]);
413
+ let replayed = assistant_with(vec![call("book", r#"{"flight":{"id":"KQ999"},"seats":2}"#)]);
414
+ let detail =
415
+ message_divergence(&[recorded], &[replayed]).expect("a change is a divergence");
416
+ assert!(
417
+ detail.contains("book.flight.id"),
418
+ "names the path: {detail}"
419
+ );
420
+ assert!(
421
+ detail.contains("KQ311"),
422
+ "shows what was recorded: {detail}"
423
+ );
424
+ assert!(
425
+ detail.contains("KQ999"),
426
+ "shows what was replayed: {detail}"
427
+ );
428
+ // The unchanged argument must not be listed as noise.
429
+ assert!(!detail.contains("seats"), "only the moved path: {detail}");
430
+ }
431
+
432
+ /// An argument that APPEARS is drift too, and the most likely shape of
433
+ /// a real regression: an agent starts passing something extra.
434
+ #[test]
435
+ fn an_added_argument_is_reported_as_absent_before() {
436
+ let recorded = assistant_with(vec![call("book", r#"{"id":"KQ311"}"#)]);
437
+ let replayed = assistant_with(vec![call("book", r#"{"id":"KQ311","override":true}"#)]);
438
+ let detail =
439
+ message_divergence(&[recorded], &[replayed]).expect("an added argument diverges");
440
+ assert!(detail.contains("book.override"), "{detail}");
441
+ assert!(detail.contains("absent"), "{detail}");
442
+ }
443
+
444
+ /// A DIFFERENT tool is a different failure and must keep the name lists,
445
+ /// which are the useful thing in that case.
446
+ #[test]
447
+ fn a_different_tool_still_reports_the_names() {
448
+ let recorded = assistant_with(vec![call("book", "{}")]);
449
+ let replayed = assistant_with(vec![call("cancel", "{}")]);
450
+ let detail =
451
+ message_divergence(&[recorded], &[replayed]).expect("a different tool diverges");
452
+ assert!(detail.contains("tool calls changed"), "{detail}");
453
+ assert!(
454
+ detail.contains("book") && detail.contains("cancel"),
455
+ "{detail}"
456
+ );
457
+ }
458
+
459
+ /// Arguments that are not JSON cannot be diffed field by field. Say the
460
+ /// whole thing moved rather than pretending to be precise about it.
461
+ #[test]
462
+ fn unparseable_arguments_report_the_whole_payload() {
463
+ let recorded = assistant_with(vec![call("book", "not json")]);
464
+ let replayed = assistant_with(vec![call("book", "also not json")]);
465
+ let detail = message_divergence(&[recorded], &[replayed]).expect("still a divergence");
466
+ assert!(detail.contains("book.arguments"), "{detail}");
467
+ }
468
+
383
469
  /// A two-turn booking trajectory: the model asks for a tool, the tool
384
470
  /// result comes back, the model replies.
385
471
  fn booking() -> Cassette {
@@ -271,7 +271,10 @@ fn prompt_change(before: &[Message], after: &[Message]) -> Option<String> {
271
271
  ///
272
272
  /// Field-level on purpose. Two JSON blobs side by side make a reviewer do
273
273
  /// the diffing; `flight.id KQ311 -> KQ999` is the finding itself.
274
- fn argument_changes(before: &ToolCall, after: &ToolCall) -> Vec<(String, String, String)> {
274
+ pub(crate) fn argument_changes(
275
+ before: &ToolCall,
276
+ after: &ToolCall,
277
+ ) -> Vec<(String, String, String)> {
275
278
  let (Some(old), Some(new)) = (before.arguments_json(), after.arguments_json()) else {
276
279
  // Unparseable arguments cannot be compared field by field. Say
277
280
  // the whole thing moved rather than pretending to be precise.
@@ -24,7 +24,7 @@ from flowproof.flow import (
24
24
  run,
25
25
  )
26
26
 
27
- __version__ = "0.6.0"
27
+ __version__ = "0.7.0"
28
28
 
29
29
  __all__ = [
30
30
  "ClarificationNeeded",
@@ -4,8 +4,8 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "flowproof"
7
- version = "0.6.0"
8
- description = "Generic open-source automation framework for the AI-agent era: automated testing and agentic process automation across web, desktop, and Citrix."
7
+ version = "0.7.0"
8
+ description = "Deterministic tests for AI agents: record a run once, replay it with zero LLM calls. Assert which tools an agent called, with which arguments, in which order."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
11
  requires-python = ">=3.9"
@@ -1,72 +0,0 @@
1
- //! Shipped examples must stay honest: every step parses and resolves
2
- //! through the deterministic rules — no live system needed, no model
3
- //! backend. This is the same role `documented_grammar_examples_all_
4
- //! resolve` plays for docs/authoring.md.
5
-
6
- use flowproof_agent::{FlowSpec, SuiteManifest};
7
-
8
- const FIORI_SPEC: &str = include_str!("../../../examples/fiori/manage-info-records.flow.yaml");
9
- const FIORI_SUITE: &str = include_str!("../../../examples/fiori/suite.yaml");
10
- const CONN_TEST_SPEC: &str = include_str!("../../../examples/api/connection-test.flow.yaml");
11
-
12
- #[test]
13
- fn connection_test_example_resolves_with_body_and_headers() {
14
- let spec = FlowSpec::parse(CONN_TEST_SPEC).expect("example parses");
15
- assert_eq!(spec.app.id(), "api");
16
- let actions =
17
- flowproof_agent::rules::resolve_step(spec.app.id(), &spec.steps[0]).expect("step resolves");
18
- let flowproof_agent::rules::ResolvedAction::AssertApi {
19
- headers,
20
- body,
21
- status,
22
- ..
23
- } = &actions[0]
24
- else {
25
- panic!("expected AssertApi");
26
- };
27
- // Raw refs in the resolved action — resolution is probe-time only.
28
- assert_eq!(
29
- headers.get("Authorization").map(String::as_str),
30
- Some("Bearer ${DM_SESSION_TOKEN}")
31
- );
32
- let body = body.as_ref().expect("body present");
33
- assert_eq!(body["connectionString"], "${TEST_CONN_STRING}");
34
- // Pinned to the real DataMaker /connections/test contract the example
35
- // mirrors (the api_pipeline mock speaks the same shape): the field is
36
- // `type`, and an unsupported provider answers 500. Drift fails here.
37
- assert_eq!(body["type"], "postgres");
38
- assert_eq!(*status, Some(500));
39
- }
40
-
41
- #[test]
42
- fn fiori_example_resolves_entirely_via_rules() {
43
- let spec = FlowSpec::parse(FIORI_SPEC).expect("example parses");
44
- assert_eq!(spec.app.id(), "web", "Fiori is a browser app");
45
- assert!(
46
- spec.url
47
- .as_deref()
48
- .unwrap_or_default()
49
- .contains("${FIORI_BASE_URL}"),
50
- "launch URL stays a ${{VAR}} reference"
51
- );
52
- for step in &spec.steps {
53
- let actions = flowproof_agent::rules::resolve_step(spec.app.id(), step)
54
- .unwrap_or_else(|e| panic!("step '{}' must resolve via rules: {e}", step.intent()));
55
- assert!(
56
- !actions.is_empty(),
57
- "step '{}' yields actions",
58
- step.intent()
59
- );
60
- }
61
- }
62
-
63
- #[test]
64
- fn fiori_suite_manifest_declares_the_data_leg() {
65
- let manifest: SuiteManifest = serde_yaml::from_str(FIORI_SUITE).expect("suite.yaml parses");
66
- let cmd = manifest.env_from.expect("env_from present");
67
- assert!(
68
- cmd.contains("datamaker"),
69
- "data comes from the DataMaker CLI"
70
- );
71
- assert!(manifest.env.contains_key("FIORI_BASE_URL"));
72
- }
File without changes
File without changes
File without changes