fluxloop-cli 0.2.28__tar.gz → 0.2.30__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 (71) hide show
  1. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/PKG-INFO +12 -1
  2. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/README.md +11 -0
  3. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/__init__.py +1 -1
  4. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/init.py +68 -0
  5. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/run.py +124 -134
  6. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/templates.py +25 -0
  7. fluxloop_cli-0.2.30/fluxloop_cli/testing/__init__.py +24 -0
  8. fluxloop_cli-0.2.30/fluxloop_cli/testing/pytest_plugin.py +421 -0
  9. fluxloop_cli-0.2.30/fluxloop_cli/testing/types.py +188 -0
  10. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/PKG-INFO +12 -1
  11. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/SOURCES.txt +7 -1
  12. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/pyproject.toml +1 -1
  13. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_evaluation_llm.py +1 -1
  14. fluxloop_cli-0.2.30/tests/test_init_pytest_template.py +62 -0
  15. fluxloop_cli-0.2.30/tests/test_pytest_plugin.py +103 -0
  16. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_run_command.py +2 -4
  17. fluxloop_cli-0.2.30/tests/test_testing_types.py +66 -0
  18. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/arg_binder.py +0 -0
  19. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/__init__.py +0 -0
  20. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/config.py +0 -0
  21. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/doctor.py +0 -0
  22. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/evaluate.py +0 -0
  23. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/generate.py +0 -0
  24. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/parse.py +0 -0
  25. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/record.py +0 -0
  26. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/commands/status.py +0 -0
  27. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/config_loader.py +0 -0
  28. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/config_schema.py +0 -0
  29. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/constants.py +0 -0
  30. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/conversation_supervisor.py +0 -0
  31. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/environment.py +0 -0
  32. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/__init__.py +0 -0
  33. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/artifacts.py +0 -0
  34. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/config.py +0 -0
  35. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/__init__.py +0 -0
  36. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/analysis.py +0 -0
  37. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/core.py +0 -0
  38. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/__init__.py +0 -0
  39. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/html.py +0 -0
  40. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/reporting/markdown.py +0 -0
  41. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/engine/success.py +0 -0
  42. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/llm.py +0 -0
  43. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/__init__.py +0 -0
  44. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/base.py +0 -0
  45. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/information_completeness.py +0 -0
  46. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/intent_recognition.py +0 -0
  47. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/response_clarity.py +0 -0
  48. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/prompts/response_consistency.py +0 -0
  49. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/evaluation/rules.py +0 -0
  50. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/input_generator.py +0 -0
  51. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/llm_generator.py +0 -0
  52. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/main.py +0 -0
  53. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/project_paths.py +0 -0
  54. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/runner.py +0 -0
  55. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/target_loader.py +0 -0
  56. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli/validators.py +0 -0
  57. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/dependency_links.txt +0 -0
  58. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/entry_points.txt +0 -0
  59. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/requires.txt +0 -0
  60. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/fluxloop_cli.egg-info/top_level.txt +0 -0
  61. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/setup.cfg +0 -0
  62. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_analysis_recommendations.py +0 -0
  63. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_arg_binder.py +0 -0
  64. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_config_command.py +0 -0
  65. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_conversation_supervisor.py +0 -0
  66. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_evaluate_command.py +0 -0
  67. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_input_generator.py +0 -0
  68. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_prompt_library.py +0 -0
  69. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_runner_multi_turn.py +0 -0
  70. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_success_criteria.py +0 -0
  71. {fluxloop_cli-0.2.28 → fluxloop_cli-0.2.30}/tests/test_target_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fluxloop-cli
3
- Version: 0.2.28
3
+ Version: 0.2.30
4
4
  Summary: FluxLoop CLI for running agent simulations
5
5
  Author-email: FluxLoop Team <team@fluxloop.dev>
6
6
  License: Apache-2.0
@@ -71,6 +71,7 @@ The legacy `setting.yaml` is still supported, but new projects created with
71
71
  - `fluxloop config set-llm` – update LLM provider/model in `configs/input.yaml`
72
72
  - `fluxloop record enable|disable|status` – toggle recording mode across `.env` and simulation config
73
73
  - `fluxloop doctor` – summarize Python, FluxLoop CLI/MCP, and MCP index state for the active environment
74
+ - `--yes/-y` (for `fluxloop run experiment`) – skip the interactive confirmation prompt, ideal for CI and the Pytest bridge
74
75
 
75
76
  ### Multi-turn supervisor options
76
77
 
@@ -88,6 +89,16 @@ These flags override the values in `configs/simulation.yaml` (`multi_turn` block
88
89
 
89
90
  Run `fluxloop --help` or `fluxloop <command> --help` for more detail.
90
91
 
92
+ ## Pytest Bridge (0.2.29+)
93
+
94
+ - `fluxloop init pytest-template [project_root]` creates `tests/test_fluxloop_smoke.py`, already wired to the new `fluxloop_runner` fixture.
95
+ - Fixtures live in `fluxloop_cli.testing.pytest_plugin` and return a `FluxLoopTestResult`, so you can assert on `total_runs`, `success_rate`, or call `require_success()`.
96
+ - Full guide + CI example: see `docs/guides/pytest_bridge.md` (includes GitHub Actions workflow at `examples/ci/fluxloop_pytest.yml`).
97
+ - Typical workflow:
98
+ 1. `pip install -e packages/cli[dev]`
99
+ 2. `fluxloop init pytest-template .`
100
+ 3. `pytest -k fluxloop_smoke --maxfail=1`
101
+
91
102
  ## Evaluation Workflow
92
103
 
93
104
  Evaluation now follows a two-step process so that multi-turn context is preserved:
@@ -30,6 +30,7 @@ The legacy `setting.yaml` is still supported, but new projects created with
30
30
  - `fluxloop config set-llm` – update LLM provider/model in `configs/input.yaml`
31
31
  - `fluxloop record enable|disable|status` – toggle recording mode across `.env` and simulation config
32
32
  - `fluxloop doctor` – summarize Python, FluxLoop CLI/MCP, and MCP index state for the active environment
33
+ - `--yes/-y` (for `fluxloop run experiment`) – skip the interactive confirmation prompt, ideal for CI and the Pytest bridge
33
34
 
34
35
  ### Multi-turn supervisor options
35
36
 
@@ -47,6 +48,16 @@ These flags override the values in `configs/simulation.yaml` (`multi_turn` block
47
48
 
48
49
  Run `fluxloop --help` or `fluxloop <command> --help` for more detail.
49
50
 
51
+ ## Pytest Bridge (0.2.29+)
52
+
53
+ - `fluxloop init pytest-template [project_root]` creates `tests/test_fluxloop_smoke.py`, already wired to the new `fluxloop_runner` fixture.
54
+ - Fixtures live in `fluxloop_cli.testing.pytest_plugin` and return a `FluxLoopTestResult`, so you can assert on `total_runs`, `success_rate`, or call `require_success()`.
55
+ - Full guide + CI example: see `docs/guides/pytest_bridge.md` (includes GitHub Actions workflow at `examples/ci/fluxloop_pytest.yml`).
56
+ - Typical workflow:
57
+ 1. `pip install -e packages/cli[dev]`
58
+ 2. `fluxloop init pytest-template .`
59
+ 3. `pytest -k fluxloop_smoke --maxfail=1`
60
+
50
61
  ## Evaluation Workflow
51
62
 
52
63
  Evaluation now follows a two-step process so that multi-turn context is preserved:
@@ -2,7 +2,7 @@
2
2
  FluxLoop CLI - Command-line interface for running agent simulations.
3
3
  """
4
4
 
5
- __version__ = "0.2.28"
5
+ __version__ = "0.2.30"
6
6
 
7
7
  from .main import app
8
8
 
@@ -18,6 +18,7 @@ from ..templates import (
18
18
  create_sample_agent,
19
19
  create_gitignore,
20
20
  create_env_file,
21
+ create_pytest_bridge_template,
21
22
  )
22
23
  from ..project_paths import resolve_root_dir, resolve_project_dir
23
24
  from ..constants import (
@@ -191,6 +192,73 @@ def project(
191
192
  console.print("8. Diagnose environment anytime: [green]fluxloop doctor[/green]")
192
193
 
193
194
 
195
+ @app.command("pytest-template")
196
+ def pytest_template(
197
+ project_root: Path = typer.Argument(
198
+ Path.cwd(),
199
+ help="Project root containing configs/ or setting.yaml",
200
+ ),
201
+ tests_dir: str = typer.Option(
202
+ "tests",
203
+ "--tests-dir",
204
+ help="Directory (relative to project root) where tests live",
205
+ ),
206
+ filename: str = typer.Option(
207
+ "test_fluxloop_smoke.py",
208
+ "--filename",
209
+ help="Test file name to create inside the tests directory",
210
+ ),
211
+ force: bool = typer.Option(
212
+ False,
213
+ "--force",
214
+ help="Overwrite existing template without confirmation",
215
+ ),
216
+ ) -> None:
217
+ """
218
+ Scaffold a pytest smoke test that uses the FluxLoop runner fixtures.
219
+ """
220
+
221
+ root_path = project_root.expanduser().resolve()
222
+ if not root_path.exists():
223
+ console.print(f"[red]Error:[/red] Project root {root_path} does not exist.")
224
+ raise typer.Exit(1)
225
+
226
+ tests_path = (root_path / tests_dir).resolve()
227
+ tests_path.mkdir(parents=True, exist_ok=True)
228
+
229
+ target_file = tests_path / filename
230
+
231
+ if target_file.exists() and not force:
232
+ if not Confirm.ask(
233
+ f"{target_file} already exists. Overwrite?",
234
+ default=False,
235
+ ):
236
+ raise typer.Abort()
237
+
238
+ configs_sim = root_path / CONFIG_DIRECTORY_NAME / CONFIG_SECTION_FILENAMES["simulation"]
239
+ legacy_config = root_path / "setting.yaml"
240
+
241
+ if configs_sim.exists():
242
+ relative_config = configs_sim.relative_to(root_path).as_posix()
243
+ elif legacy_config.exists():
244
+ relative_config = legacy_config.relative_to(root_path).as_posix()
245
+ else:
246
+ # Fall back to configs/simulation.yaml even if it does not exist yet
247
+ relative_config = (CONFIG_DIRECTORY_NAME + "/" + CONFIG_SECTION_FILENAMES["simulation"])
248
+ console.print(
249
+ "[yellow]Warning:[/yellow] Could not find configs/simulation.yaml or setting.yaml. "
250
+ "Template will reference the default simulation path."
251
+ )
252
+
253
+ template_content = create_pytest_bridge_template(relative_config)
254
+ target_file.write_text(template_content, encoding="utf-8")
255
+
256
+ console.print(
257
+ f"[green]✓[/green] Pytest template created at [cyan]{target_file}[/cyan]. "
258
+ "Run [bold]pytest -k fluxloop_smoke[/bold] to execute the sample test."
259
+ )
260
+
261
+
194
262
  @app.command()
195
263
  def agent(
196
264
  name: str = typer.Argument(
@@ -3,10 +3,9 @@ Run command for executing experiments and simulations.
3
3
  """
4
4
 
5
5
  import asyncio
6
- import json
7
6
  import sys
8
7
  from pathlib import Path
9
- from typing import Optional
8
+ from typing import Callable, Optional
10
9
 
11
10
  import typer
12
11
  from rich.console import Console
@@ -28,16 +27,6 @@ app = typer.Typer()
28
27
  console = Console()
29
28
 
30
29
 
31
- def _emit_progress_event(event: str, **payload: object) -> None:
32
- """Emit a structured progress event to stderr for machine consumption."""
33
- data = {"event": event, **payload}
34
- try:
35
- print(json.dumps(data, default=str), file=sys.stderr, flush=True)
36
- except Exception:
37
- # Fall back to repr if serialization fails
38
- print({"event": event, **{k: repr(v) for k, v in payload.items()}}, file=sys.stderr, flush=True)
39
-
40
-
41
30
  @app.command()
42
31
  def experiment(
43
32
  config_file: Path = typer.Option(
@@ -124,6 +113,17 @@ def experiment(
124
113
  "--dry-run",
125
114
  help="Show what would be run without executing",
126
115
  ),
116
+ display: bool = typer.Option(
117
+ True,
118
+ "--display/--no-display",
119
+ help="Show rich console output (disable for plain log streaming)",
120
+ ),
121
+ yes: bool = typer.Option(
122
+ False,
123
+ "--yes",
124
+ "-y",
125
+ help="Skip confirmation prompt and run immediately",
126
+ ),
127
127
  ):
128
128
  """
129
129
  Run an experiment based on configuration file.
@@ -239,19 +239,8 @@ def experiment(
239
239
 
240
240
  console.print(summary)
241
241
 
242
- _emit_progress_event(
243
- "experiment_summary",
244
- name=config.name,
245
- iterations=config.iterations,
246
- personas=len(config.personas),
247
- total_runs=total_runs,
248
- multi_turn_enabled=bool(config.multi_turn and config.multi_turn.enabled),
249
- output_dir=config.output_directory,
250
- )
251
-
252
242
  if dry_run:
253
243
  console.print("\n[yellow]Dry run mode - no execution will occur[/yellow]")
254
- _emit_progress_event("experiment_status", status="dry_run", name=config.name)
255
244
  return
256
245
 
257
246
  # Confirm execution
@@ -265,128 +254,138 @@ def experiment(
265
254
  f"\nThis will execute {total_runs} runs."
266
255
  )
267
256
 
268
- _emit_progress_event(
269
- "experiment_ready",
270
- name=config.name,
271
- total_runs=total_runs,
272
- )
273
-
274
- if not typer.confirm("Continue?"):
275
- _emit_progress_event("experiment_status", status="aborted", reason="user_declined", name=config.name)
257
+ if yes:
258
+ proceed = True
259
+ else:
260
+ proceed = typer.confirm("Continue?")
261
+ if not proceed:
276
262
  raise typer.Abort()
277
263
 
278
264
  # Create runner
279
265
  # Run experiment with progress tracking
280
266
  console.print("\n[bold green]▶️ Starting experiment...[/bold green]\n")
281
267
 
282
- _emit_progress_event("experiment_started", name=config.name, total_runs=total_runs)
283
-
284
- with Progress(
285
- SpinnerColumn(),
286
- TextColumn("[progress.description]{task.description}"),
287
- BarColumn(),
288
- TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
289
- TimeRemainingColumn(),
290
- TextColumn("({task.completed} of {task.total})"),
291
- console=console,
292
- ) as progress:
293
- # Create main task
294
- main_task = progress.add_task(
295
- f"Running {config.name}",
296
- total=total_runs,
297
- )
298
- multi_turn_total = (
299
- config.multi_turn.max_turns
300
- if config.multi_turn and config.multi_turn.max_turns
301
- else None
302
- )
303
- turn_task = progress.add_task(
304
- "[cyan]Turn 0/0",
305
- total=multi_turn_total or 1,
306
- visible=False,
307
- )
268
+ def _execute_with_callbacks(
269
+ progress_callback: Optional[Callable[[], None]],
270
+ turn_progress_callback: Optional[Callable[[int, int, Optional[str]], None]],
271
+ ):
272
+ try:
273
+ return asyncio.run(
274
+ runner.run_experiment(
275
+ progress_callback=progress_callback,
276
+ turn_progress_callback=turn_progress_callback,
277
+ )
278
+ )
279
+ except KeyboardInterrupt:
280
+ console.print("\n[yellow]Experiment interrupted by user[/yellow]")
281
+ raise typer.Exit(1)
282
+ except Exception as e:
283
+ console.print(f"\n[red]Error during experiment:[/red] {e}")
284
+ if "--debug" in sys.argv:
285
+ console.print_exception()
286
+ raise typer.Exit(1)
308
287
 
309
- def _progress_callback():
310
- progress.advance(main_task)
311
- task = progress.tasks[main_task]
312
- _emit_progress_event(
313
- "run_progress",
314
- completed=int(task.completed),
315
- total=int(task.total or 0),
316
- description=task.description,
317
- experiment=config.name,
288
+ if display and console.is_terminal:
289
+ with Progress(
290
+ SpinnerColumn(),
291
+ TextColumn("[progress.description]{task.description}"),
292
+ BarColumn(),
293
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
294
+ TimeRemainingColumn(),
295
+ TextColumn("({task.completed} of {task.total})"),
296
+ console=console,
297
+ ) as progress:
298
+ main_task = progress.add_task(
299
+ f"Running {config.name}",
300
+ total=total_runs,
301
+ )
302
+ multi_turn_total = (
303
+ config.multi_turn.max_turns
304
+ if config.multi_turn and config.multi_turn.max_turns
305
+ else None
306
+ )
307
+ turn_task = progress.add_task(
308
+ "[cyan]Turn 0/0",
309
+ total=multi_turn_total or 1,
310
+ visible=False,
318
311
  )
319
312
 
320
- def _turn_progress_callback(
321
- current_turn: int,
322
- total_turns: int,
323
- message: Optional[str] = None,
324
- ) -> None:
325
- total = total_turns or 1
326
- clamped_turn = max(0, current_turn)
327
- if message is None:
328
- completed = min(clamped_turn, total)
329
- description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: complete"
313
+ def _progress_callback() -> None:
314
+ progress.advance(main_task)
315
+
316
+ def _turn_progress_callback(
317
+ current_turn: int,
318
+ total_turns: int,
319
+ message: Optional[str] = None,
320
+ ) -> None:
321
+ total = total_turns or 1
322
+ clamped_turn = max(0, current_turn)
323
+ if message is None:
324
+ completed = min(clamped_turn, total)
325
+ description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: complete"
326
+ progress.update(
327
+ turn_task,
328
+ total=total,
329
+ completed=completed,
330
+ description=description,
331
+ visible=False,
332
+ )
333
+ return
334
+
335
+ if not progress.tasks[turn_task].visible:
336
+ progress.update(turn_task, visible=True)
337
+
338
+ preview = message.replace("\n", " ")
339
+ if len(preview) > 40:
340
+ preview = preview[:37] + "..."
341
+
342
+ completed = max(0, min(clamped_turn - 1, total))
343
+ description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: {preview}"
330
344
  progress.update(
331
345
  turn_task,
332
346
  total=total,
333
347
  completed=completed,
334
348
  description=description,
335
- visible=False,
336
- )
337
- _emit_progress_event(
338
- "turn_progress",
339
- state="complete",
340
- current=min(clamped_turn, total),
341
- total=total,
342
- experiment=config.name,
343
349
  )
344
- return
345
-
346
- if not progress.tasks[turn_task].visible:
347
- progress.update(turn_task, visible=True)
348
350
 
349
- preview = message.replace("\n", " ")
350
- if len(preview) > 40:
351
- preview = preview[:37] + "..."
352
-
353
- completed = max(0, min(clamped_turn - 1, total))
354
- description = f"[cyan]Turn {min(clamped_turn, total)}/{total}: {preview}"
351
+ results = _execute_with_callbacks(_progress_callback, _turn_progress_callback)
352
+ else:
353
+ completed_runs = 0
355
354
 
356
- progress.update(
357
- turn_task,
358
- total=total,
359
- completed=completed,
360
- description=description,
361
- )
362
- _emit_progress_event(
363
- "turn_progress",
364
- state="in_progress",
365
- current=clamped_turn,
366
- total=total,
367
- preview=preview,
368
- experiment=config.name,
355
+ def _progress_callback() -> None:
356
+ nonlocal completed_runs
357
+ completed_runs += 1
358
+ print(
359
+ f"[fluxloop] run progress {completed_runs}/{total_runs}",
360
+ flush=True,
369
361
  )
370
362
 
371
- # Run experiment
372
- try:
373
- results = asyncio.run(
374
- runner.run_experiment(
375
- progress_callback=_progress_callback,
376
- turn_progress_callback=_turn_progress_callback,
363
+ def _turn_progress_callback(
364
+ current_turn: int,
365
+ total_turns: int,
366
+ message: Optional[str] = None,
367
+ ) -> None:
368
+ total = total_turns or 1
369
+ clamped_turn = max(0, current_turn)
370
+ if message:
371
+ preview = message.replace("\n", " ")
372
+ if len(preview) > 80:
373
+ preview = preview[:77] + "..."
374
+ print(
375
+ f"[fluxloop] turn progress {min(clamped_turn, total)}/{total}: {preview}",
376
+ flush=True,
377
377
  )
378
- )
379
- except KeyboardInterrupt:
380
- console.print("\n[yellow]Experiment interrupted by user[/yellow]")
381
- _emit_progress_event("experiment_status", status="interrupted", name=config.name)
382
- raise typer.Exit(1)
383
- except Exception as e:
384
- console.print(f"\n[red]Error during experiment:[/red] {e}")
385
- # Debug mode - show full traceback
386
- if "--debug" in sys.argv:
387
- console.print_exception()
388
- _emit_progress_event("experiment_status", status="error", error=str(e), name=config.name)
389
- raise typer.Exit(1)
378
+ else:
379
+ print(
380
+ f"[fluxloop] turn progress {min(clamped_turn, total)}/{total}: complete",
381
+ flush=True,
382
+ )
383
+
384
+ print(
385
+ f"[fluxloop] running {config.name} ({total_runs} total runs)",
386
+ flush=True,
387
+ )
388
+ results = _execute_with_callbacks(_progress_callback, _turn_progress_callback)
390
389
 
391
390
  config.set_resolved_input_count(results.get("input_count", config.get_input_count()))
392
391
 
@@ -399,15 +398,6 @@ def experiment(
399
398
  )
400
399
 
401
400
  _display_results(results)
402
- _emit_progress_event(
403
- "experiment_status",
404
- status="completed",
405
- name=config.name,
406
- total_runs=results.get("total_runs", total_runs),
407
- successful=results.get("successful"),
408
- failed=results.get("failed"),
409
- success_rate=results.get("success_rate"),
410
- )
411
401
 
412
402
 
413
403
  @app.command()
@@ -429,6 +429,31 @@ def create_evaluation_config() -> str:
429
429
  ).strip() + "\n"
430
430
 
431
431
 
432
+ def create_pytest_bridge_template(config_relative_path: str) -> str:
433
+ """Return a ready-to-run pytest smoke test referencing the bridge fixtures."""
434
+
435
+ return dedent(
436
+ f"""
437
+ \"\"\"FluxLoop Pytest smoke test generated via `fluxloop init pytest-template`.\"\"\"
438
+
439
+ from pathlib import Path
440
+
441
+
442
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
443
+ SIMULATION_CONFIG = PROJECT_ROOT / "{config_relative_path}"
444
+
445
+
446
+ def test_fluxloop_smoke(fluxloop_runner):
447
+ result = fluxloop_runner(
448
+ project_root=PROJECT_ROOT,
449
+ simulation_config=SIMULATION_CONFIG,
450
+ env={{"PYTHONPATH": str(PROJECT_ROOT)}},
451
+ )
452
+ result.require_success(label="fluxloop smoke")
453
+ """
454
+ ).strip() + "\n"
455
+
456
+
432
457
  def create_sample_agent() -> str:
433
458
  """Create a sample agent implementation."""
434
459
 
@@ -0,0 +1,24 @@
1
+ """
2
+ Testing utilities interface definitions for FluxLoop Pytest bridge.
3
+ """
4
+
5
+ from .types import (
6
+ DEFAULT_SCENARIOS,
7
+ FluxLoopRunnerMode,
8
+ FluxLoopRunnerRequest,
9
+ FluxLoopRunnerOverrides,
10
+ FluxLoopTestError,
11
+ FluxLoopTestResult,
12
+ FluxLoopTestScenario,
13
+ )
14
+
15
+ __all__ = [
16
+ "DEFAULT_SCENARIOS",
17
+ "FluxLoopRunnerMode",
18
+ "FluxLoopRunnerOverrides",
19
+ "FluxLoopRunnerRequest",
20
+ "FluxLoopTestError",
21
+ "FluxLoopTestResult",
22
+ "FluxLoopTestScenario",
23
+ ]
24
+