applied-cli 0.6.2__tar.gz → 0.6.4__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 (68) hide show
  1. {applied_cli-0.6.2 → applied_cli-0.6.4}/PKG-INFO +1 -1
  2. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/cli.py +36 -0
  3. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/tools.py +143 -2
  4. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/domains.py +1 -0
  5. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/scenarios.py +67 -2
  6. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli.egg-info/PKG-INFO +1 -1
  7. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli.egg-info/SOURCES.txt +2 -0
  8. {applied_cli-0.6.2 → applied_cli-0.6.4}/pyproject.toml +1 -1
  9. applied_cli-0.6.4/tests/test_scenario_bulk_cancel.py +87 -0
  10. applied_cli-0.6.4/tests/test_scenario_bulk_run_contact.py +116 -0
  11. {applied_cli-0.6.2 → applied_cli-0.6.4}/README.md +0 -0
  12. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/__init__.py +0 -0
  13. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/agent_scoped_flows.py +0 -0
  14. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/auth.py +0 -0
  15. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/client.py +0 -0
  16. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/conversation_lookup.py +0 -0
  17. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/conversations.py +0 -0
  18. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/credentials.py +0 -0
  19. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/flow_helpers.py +0 -0
  20. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/formatters.py +0 -0
  21. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/mcp.py +0 -0
  22. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/recovery.py +0 -0
  23. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/toolkit.py +0 -0
  24. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/__init__.py +0 -0
  25. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/agents.py +0 -0
  26. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/articles.py +0 -0
  27. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/catalog.py +0 -0
  28. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/connectors.py +0 -0
  29. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/content.py +0 -0
  30. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/conversations.py +0 -0
  31. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/flows.py +0 -0
  32. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/knowledge.py +0 -0
  33. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/manifest.py +0 -0
  34. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/products.py +0 -0
  35. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/taxonomy.py +0 -0
  36. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli/v2/tickets.py +0 -0
  37. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli.egg-info/dependency_links.txt +0 -0
  38. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli.egg-info/entry_points.txt +0 -0
  39. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli.egg-info/requires.txt +0 -0
  40. {applied_cli-0.6.2 → applied_cli-0.6.4}/applied_cli.egg-info/top_level.txt +0 -0
  41. {applied_cli-0.6.2 → applied_cli-0.6.4}/setup.cfg +0 -0
  42. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_agent_scoped_flows.py +0 -0
  43. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_audit_tools.py +0 -0
  44. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_auth_context.py +0 -0
  45. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_benchmark_clone.py +0 -0
  46. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_benchmark_delete_guardrail.py +0 -0
  47. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_benchmark_scenario_tools.py +0 -0
  48. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_cli.py +0 -0
  49. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_cli_v2.py +0 -0
  50. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_client.py +0 -0
  51. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_client_v2.py +0 -0
  52. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_conversation_tools.py +0 -0
  53. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_flow_tools.py +0 -0
  54. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_knowledge_content_tools.py +0 -0
  55. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_recovery.py +0 -0
  56. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_toolkit_contract.py +0 -0
  57. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_agents.py +0 -0
  58. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_articles.py +0 -0
  59. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_catalog_and_mcp.py +0 -0
  60. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_connectors.py +0 -0
  61. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_content.py +0 -0
  62. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_conversations.py +0 -0
  63. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_flows.py +0 -0
  64. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_knowledge.py +0 -0
  65. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_products.py +0 -0
  66. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_scenarios.py +0 -0
  67. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_taxonomy.py +0 -0
  68. {applied_cli-0.6.2 → applied_cli-0.6.4}/tests/test_v2_tickets.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -1916,6 +1916,18 @@ def scenario_bulk_run(
1916
1916
  target_agent_id: str = typer.Option(
1917
1917
  None, "--target-agent-id", help="Optional target agent for reruns"
1918
1918
  ),
1919
+ contact_email: str = typer.Option(
1920
+ None,
1921
+ "--contact-email",
1922
+ help="Run as a contact with this email (resolves/creates it) so test "
1923
+ "conversations carry an email — fixes 'Email is not present' failures",
1924
+ ),
1925
+ contact_id: str = typer.Option(
1926
+ None, "--contact-id", help="Run scenarios as this existing contact UUID"
1927
+ ),
1928
+ anonymous: bool = typer.Option(
1929
+ False, "--anonymous", help="Run with an anonymous contact"
1930
+ ),
1919
1931
  shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
1920
1932
  format: str = typer.Option(
1921
1933
  "text", "--format", "-f", help="Output format: text or json"
@@ -1929,6 +1941,9 @@ def scenario_bulk_run(
1929
1941
  scenario_ids=_parse_csv_option(scenario_ids),
1930
1942
  benchmark_id=benchmark_id,
1931
1943
  target_agent_id=target_agent_id,
1944
+ contact_email=contact_email,
1945
+ contact_id=contact_id,
1946
+ anonymous=anonymous,
1932
1947
  output_format=format,
1933
1948
  )
1934
1949
  )
@@ -1956,6 +1971,27 @@ def scenario_bulk_status(
1956
1971
  typer.echo(result)
1957
1972
 
1958
1973
 
1974
+ @app.command("scenario-bulk-cancel")
1975
+ def scenario_bulk_cancel(
1976
+ job_id: str = typer.Argument(..., help="Bulk run job ID"),
1977
+ apply: bool = typer.Option(
1978
+ False, "--apply", help="Cancel the pending runs (default is a dry-run plan)"
1979
+ ),
1980
+ shop_id: str = typer.Option(None, "--shop-id", help="Override shop ID"),
1981
+ format: str = typer.Option(
1982
+ "text", "--format", "-f", help="Output format: text or json"
1983
+ ),
1984
+ ) -> None:
1985
+ """Cancel a stuck bulk run by deleting its queued/running scenario runs."""
1986
+ client = get_client(shop_id=shop_id)
1987
+ result = asyncio.run(
1988
+ tools.scenario_bulk_cancel(
1989
+ client, job_id=job_id, apply=apply, output_format=format
1990
+ )
1991
+ )
1992
+ typer.echo(result)
1993
+
1994
+
1959
1995
  @app.command("audit-metric-fields")
1960
1996
  def audit_metric_fields(
1961
1997
  is_active: bool = typer.Option(
@@ -6082,23 +6082,68 @@ async def scenario_run_delete(
6082
6082
  return f"Scenario run {run_id} deleted successfully."
6083
6083
 
6084
6084
 
6085
+ async def _resolve_contact_override(
6086
+ client: AppliedClient,
6087
+ *,
6088
+ contact_override: dict | None,
6089
+ contact_id: str | None,
6090
+ contact_email: str | None,
6091
+ anonymous: bool,
6092
+ ) -> dict | None:
6093
+ """Build the contact_override payload from the convenience arguments.
6094
+
6095
+ An explicit contact_override dict wins. Otherwise anonymous > contact_id >
6096
+ contact_email (which resolves/creates a contact so the test conversation
6097
+ carries a real email).
6098
+ """
6099
+ if contact_override:
6100
+ return contact_override
6101
+ if anonymous:
6102
+ return {"mode": "anonymous"}
6103
+ if contact_id:
6104
+ return {"mode": "contact", "contact_id": contact_id}
6105
+ if contact_email:
6106
+ contact = await client.get_or_create_contact(email=contact_email)
6107
+ resolved_id = contact.get("id")
6108
+ if not resolved_id:
6109
+ raise AppliedAPIError(
6110
+ f"Could not resolve a contact for email {contact_email}.",
6111
+ status_code=404,
6112
+ )
6113
+ return {"mode": "contact", "contact_id": str(resolved_id)}
6114
+ return None
6115
+
6116
+
6085
6117
  async def scenario_bulk_run(
6086
6118
  client: AppliedClient,
6087
6119
  scenario_ids: list[str] | None = None,
6088
6120
  benchmark_id: str | None = None,
6089
6121
  target_agent_id: str | None = None,
6090
6122
  contact_override: dict | None = None,
6123
+ contact_id: str | None = None,
6124
+ contact_email: str | None = None,
6125
+ anonymous: bool = False,
6091
6126
  output_format: str = "text",
6092
6127
  ) -> str:
6093
6128
  """
6094
6129
  Run multiple scenarios at once.
6095
6130
 
6131
+ By default a scenario run reuses the input conversation's contact, which on
6132
+ test/benchmark conversations often has no email — causing agents to respond
6133
+ "Email is not present in the conversation." Pass contact_email or contact_id
6134
+ to run the scenarios as a contact that has an email, so the test conversation
6135
+ carries it.
6136
+
6096
6137
  Args:
6097
6138
  client: Authenticated AppliedClient
6098
6139
  scenario_ids: List of scenario UUIDs to run
6099
6140
  benchmark_id: Run all scenarios in this benchmark
6100
6141
  target_agent_id: Optional agent to run against (for A/B testing)
6101
- contact_override: Optional contact override, e.g. {"mode": "contact", "contact_id": "<uuid>"}
6142
+ contact_override: Raw override, e.g. {"mode": "contact", "contact_id": "<uuid>"}
6143
+ (takes precedence over the convenience args below)
6144
+ contact_id: Run scenarios as this existing contact (gives test convos its email)
6145
+ contact_email: Resolve/create a contact with this email and run as them
6146
+ anonymous: Run with an anonymous contact (mode='anonymous')
6102
6147
 
6103
6148
  Returns:
6104
6149
  Summary of runs created
@@ -6123,10 +6168,17 @@ async def scenario_bulk_run(
6123
6168
  )
6124
6169
 
6125
6170
  try:
6171
+ effective_override = await _resolve_contact_override(
6172
+ client,
6173
+ contact_override=contact_override,
6174
+ contact_id=contact_id,
6175
+ contact_email=contact_email,
6176
+ anonymous=anonymous,
6177
+ )
6126
6178
  result = await client.bulk_run_scenarios(
6127
6179
  scenario_ids=resolved_scenario_ids,
6128
6180
  target_agent_id=target_agent_id,
6129
- contact_override=contact_override,
6181
+ contact_override=effective_override,
6130
6182
  )
6131
6183
  except AppliedAPIError as e:
6132
6184
  return _format_error(e)
@@ -6230,6 +6282,95 @@ async def scenario_bulk_status(
6230
6282
  return output
6231
6283
 
6232
6284
 
6285
+ _PENDING_RUN_STATUSES = ("queued", "running")
6286
+
6287
+
6288
+ async def scenario_bulk_cancel(
6289
+ client: AppliedClient,
6290
+ job_id: str,
6291
+ *,
6292
+ apply: bool = False,
6293
+ output_format: str = "text",
6294
+ ) -> str:
6295
+ """
6296
+ Cancel a stuck bulk scenario run job.
6297
+
6298
+ A bulk run can get stuck with runs left in 'queued' or 'running' forever, so
6299
+ the job never reports complete. This cancels the job by deleting those pending
6300
+ runs; completed and failed runs (and their result conversations) are preserved.
6301
+ Once no pending runs remain, the job reports as finished.
6302
+
6303
+ This replaces the manual workaround of deleting the 'agent-test-bulk-job:'
6304
+ key from browser localStorage.
6305
+
6306
+ Args:
6307
+ client: Authenticated AppliedClient
6308
+ job_id: The bulk run job UUID
6309
+ apply: If True, delete the pending runs; otherwise just report the plan
6310
+ output_format: 'text' (default) or 'json'
6311
+
6312
+ Returns:
6313
+ Summary of pending runs cancelled (or that would be cancelled).
6314
+ """
6315
+ try:
6316
+ runs = await client.list_scenario_runs(
6317
+ bulk_job_id=job_id, latest=False, limit=1000
6318
+ )
6319
+ except AppliedAPIError as e:
6320
+ return _format_error(e)
6321
+
6322
+ pending = [r for r in runs if str(r.get("status") or "") in _PENDING_RUN_STATUSES]
6323
+ result: dict[str, Any] = {
6324
+ "job_id": job_id,
6325
+ "apply": apply,
6326
+ "total_runs": len(runs),
6327
+ "pending_runs": len(pending),
6328
+ "terminal_runs": len(runs) - len(pending),
6329
+ "cancelled": 0,
6330
+ "errors": [],
6331
+ }
6332
+
6333
+ if not runs:
6334
+ result["message"] = (
6335
+ f"No runs found for job {job_id} — it may already be cleared or the "
6336
+ f"job id is invalid. Nothing to cancel."
6337
+ )
6338
+ return to_json(result) if output_format == "json" else result["message"]
6339
+
6340
+ if not pending:
6341
+ result["message"] = (
6342
+ f"Job {job_id} has no pending (queued/running) runs — nothing to "
6343
+ f"cancel. {result['terminal_runs']} run(s) already finished."
6344
+ )
6345
+ return to_json(result) if output_format == "json" else result["message"]
6346
+
6347
+ if not apply:
6348
+ result["message"] = (
6349
+ f"Would cancel {len(pending)} pending run(s) (queued/running) for job "
6350
+ f"{job_id}; {result['terminal_runs']} finished run(s) preserved. "
6351
+ f"Re-run with --apply to cancel."
6352
+ )
6353
+ return to_json(result) if output_format == "json" else result["message"]
6354
+
6355
+ cancelled = 0
6356
+ for run in pending:
6357
+ run_id = run.get("id")
6358
+ if not run_id:
6359
+ continue
6360
+ try:
6361
+ await client.delete_scenario_run(str(run_id))
6362
+ cancelled += 1
6363
+ except AppliedAPIError as e:
6364
+ result["errors"].append({"run_id": str(run_id), "error": str(e)})
6365
+ result["cancelled"] = cancelled
6366
+ result["message"] = (
6367
+ f"Cancelled {cancelled} pending run(s) for job {job_id}; "
6368
+ f"{result['terminal_runs']} finished run(s) preserved."
6369
+ + (f" {len(result['errors'])} error(s)." if result["errors"] else "")
6370
+ )
6371
+ return to_json(result) if output_format == "json" else result["message"]
6372
+
6373
+
6233
6374
  async def _load_audit_target_summaries(
6234
6375
  client: AppliedClient,
6235
6376
  ratings: list[dict[str, Any]],
@@ -119,6 +119,7 @@ DOMAIN_TOOL_RENAMES: dict[str, dict[str, str]] = {
119
119
  "scenario_run_delete": "scenarios_runs_delete",
120
120
  "scenario_bulk_run": "scenarios_bulk_run",
121
121
  "scenario_bulk_status": "scenarios_bulk_status",
122
+ "scenario_bulk_cancel": "scenarios_bulk_cancel",
122
123
  },
123
124
  "taxonomy": {
124
125
  "taxonomy_list": "taxonomy_items_list",
@@ -27,6 +27,14 @@ class ScenariosBulkRunInput(StrictInput):
27
27
  benchmark_id: str | None = None
28
28
  target_agent_id: str | None = None
29
29
  contact_override: dict[str, Any] | None = None
30
+ contact_id: str | None = None
31
+ contact_email: str | None = None
32
+ anonymous: bool = False
33
+
34
+
35
+ class ScenariosBulkCancelInput(StrictInput):
36
+ job_id: str
37
+ apply: bool = False
30
38
 
31
39
 
32
40
  class BenchmarksListInput(StrictInput):
@@ -791,10 +799,19 @@ async def scenarios_bulk_run_handler(
791
799
  )
792
800
 
793
801
  try:
802
+ from applied_cli.tools import _resolve_contact_override
803
+
804
+ effective_override = await _resolve_contact_override(
805
+ client,
806
+ contact_override=params.contact_override,
807
+ contact_id=params.contact_id,
808
+ contact_email=params.contact_email,
809
+ anonymous=params.anonymous,
810
+ )
794
811
  result = await client.bulk_run_scenarios(
795
812
  scenario_ids=resolved_scenario_ids,
796
813
  target_agent_id=params.target_agent_id,
797
- contact_override=params.contact_override,
814
+ contact_override=effective_override,
798
815
  )
799
816
  except AppliedAPIError as exc:
800
817
  return _api_error_result(exc)
@@ -850,6 +867,38 @@ async def scenarios_bulk_status_handler(
850
867
  )
851
868
 
852
869
 
870
+ async def scenarios_bulk_cancel_handler(
871
+ client: AppliedClient,
872
+ params: ScenariosBulkCancelInput,
873
+ ) -> ToolResult[Any]:
874
+ from applied_cli import tools as legacy_tools
875
+
876
+ raw = await legacy_tools.scenario_bulk_cancel(
877
+ client,
878
+ job_id=params.job_id,
879
+ apply=params.apply,
880
+ output_format="json",
881
+ )
882
+ try:
883
+ data = json.loads(raw)
884
+ except (json.JSONDecodeError, TypeError):
885
+ return ToolResult(data={"message": raw}, summary=str(raw))
886
+
887
+ next_actions = []
888
+ if not params.apply and data.get("pending_runs"):
889
+ next_actions.append("Re-run with apply=true to cancel the pending runs.")
890
+ elif data.get("cancelled"):
891
+ next_actions.append(
892
+ f"Poll scenarios_bulk_status with job_id='{params.job_id}' to confirm "
893
+ f"the job now reports finished."
894
+ )
895
+ return ToolResult(
896
+ data=data,
897
+ summary=data.get("message", "Bulk cancel processed."),
898
+ next_actions=next_actions,
899
+ )
900
+
901
+
853
902
  def scenario_specs() -> list[ToolSpec]:
854
903
  return [
855
904
  ToolSpec(
@@ -1013,7 +1062,9 @@ def scenario_specs() -> list[ToolSpec]:
1013
1062
  namespace="scenarios",
1014
1063
  description=(
1015
1064
  "Run selected scenarios or every scenario in a benchmark and "
1016
- "return the queued job metadata."
1065
+ "return the queued job metadata. Pass contact_email or contact_id "
1066
+ "to run as a contact with an email (fixes 'Email is not present' "
1067
+ "failures on test conversations)."
1017
1068
  ),
1018
1069
  input_model=ScenariosBulkRunInput,
1019
1070
  output_model=None,
@@ -1034,4 +1085,18 @@ def scenario_specs() -> list[ToolSpec]:
1034
1085
  read_write_mode="read",
1035
1086
  tags=["scenario_bulk_status", "native"],
1036
1087
  ),
1088
+ ToolSpec(
1089
+ name="scenarios_bulk_cancel",
1090
+ namespace="scenarios",
1091
+ description=(
1092
+ "Cancel a stuck bulk scenario run job by deleting its queued/"
1093
+ "running runs (completed/failed runs are preserved). Dry-run by "
1094
+ "default; set apply=true to cancel."
1095
+ ),
1096
+ input_model=ScenariosBulkCancelInput,
1097
+ output_model=None,
1098
+ handler=scenarios_bulk_cancel_handler,
1099
+ read_write_mode="write",
1100
+ tags=["scenario_bulk_cancel", "native"],
1101
+ ),
1037
1102
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: applied-cli
3
- Version: 0.6.2
3
+ Version: 0.6.4
4
4
  Summary: CLI and shared client library for Applied Labs AI support agents
5
5
  Author: Applied Labs
6
6
  License-Expression: MIT
@@ -49,6 +49,8 @@ tests/test_conversation_tools.py
49
49
  tests/test_flow_tools.py
50
50
  tests/test_knowledge_content_tools.py
51
51
  tests/test_recovery.py
52
+ tests/test_scenario_bulk_cancel.py
53
+ tests/test_scenario_bulk_run_contact.py
52
54
  tests/test_toolkit_contract.py
53
55
  tests/test_v2_agents.py
54
56
  tests/test_v2_articles.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "applied-cli"
3
- version = "0.6.2"
3
+ version = "0.6.4"
4
4
  description = "CLI and shared client library for Applied Labs AI support agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -0,0 +1,87 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from applied_cli import tools
6
+
7
+
8
+ class FakeBulkClient:
9
+ def __init__(self, runs):
10
+ self._runs = runs
11
+ self.deleted = []
12
+
13
+ async def list_scenario_runs(
14
+ self, scenario_id=None, benchmark_id=None, bulk_job_id=None, latest=False,
15
+ limit=50,
16
+ ):
17
+ return list(self._runs)
18
+
19
+ async def delete_scenario_run(self, run_id):
20
+ self.deleted.append(run_id)
21
+
22
+
23
+ STUCK_RUNS = [
24
+ {"id": "r1", "status": "completed"},
25
+ {"id": "r2", "status": "queued"},
26
+ {"id": "r3", "status": "running"},
27
+ {"id": "r4", "status": "failed"},
28
+ ]
29
+
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_dry_run_reports_pending_without_deleting():
33
+ client = FakeBulkClient(STUCK_RUNS)
34
+ result = await tools.scenario_bulk_cancel(client, "job-1", output_format="json")
35
+ data = json.loads(result)
36
+ assert data["pending_runs"] == 2
37
+ assert data["terminal_runs"] == 2
38
+ assert data["cancelled"] == 0
39
+ assert client.deleted == [] # dry run deletes nothing
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_apply_deletes_only_pending_runs():
44
+ client = FakeBulkClient(STUCK_RUNS)
45
+ result = await tools.scenario_bulk_cancel(
46
+ client, "job-1", apply=True, output_format="json"
47
+ )
48
+ data = json.loads(result)
49
+ assert data["cancelled"] == 2
50
+ # Only the queued/running runs are deleted; completed/failed preserved.
51
+ assert set(client.deleted) == {"r2", "r3"}
52
+
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_no_pending_runs_is_a_noop():
56
+ client = FakeBulkClient(
57
+ [{"id": "r1", "status": "completed"}, {"id": "r2", "status": "failed"}]
58
+ )
59
+ result = await tools.scenario_bulk_cancel(
60
+ client, "job-1", apply=True, output_format="text"
61
+ )
62
+ assert "nothing to" in result.lower()
63
+ assert client.deleted == []
64
+
65
+
66
+ @pytest.mark.asyncio
67
+ async def test_unknown_job_reports_no_runs():
68
+ client = FakeBulkClient([])
69
+ result = await tools.scenario_bulk_cancel(client, "missing", output_format="text")
70
+ assert "No runs found" in result
71
+ assert client.deleted == []
72
+
73
+
74
+ @pytest.mark.asyncio
75
+ async def test_v2_scenarios_bulk_cancel_handler_structured():
76
+ from applied_cli.v2.scenarios import (
77
+ ScenariosBulkCancelInput,
78
+ scenarios_bulk_cancel_handler,
79
+ )
80
+
81
+ client = FakeBulkClient(STUCK_RUNS)
82
+ result = await scenarios_bulk_cancel_handler(
83
+ client, ScenariosBulkCancelInput(job_id="job-1", apply=False)
84
+ )
85
+ assert result.data["pending_runs"] == 2
86
+ assert "apply=true" in " ".join(result.next_actions)
87
+ assert client.deleted == []
@@ -0,0 +1,116 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from applied_cli import tools
6
+
7
+
8
+ class FakeRunClient:
9
+ def __init__(self):
10
+ self.bulk_kwargs = None
11
+ self.get_or_create_calls = []
12
+
13
+ async def list_scenarios(self, benchmark_id=None, limit=500, **kwargs):
14
+ return [{"id": "s1"}, {"id": "s2"}]
15
+
16
+ async def get_or_create_contact(self, email=None, name=None, phone=None):
17
+ self.get_or_create_calls.append(email)
18
+ return {"id": "contact-123", "email": email}
19
+
20
+ async def bulk_run_scenarios(
21
+ self, scenario_ids=None, target_agent_id=None, contact_override=None
22
+ ):
23
+ self.bulk_kwargs = {
24
+ "scenario_ids": scenario_ids,
25
+ "target_agent_id": target_agent_id,
26
+ "contact_override": contact_override,
27
+ }
28
+ return {
29
+ "job_id": "job-1",
30
+ "total": len(scenario_ids or []),
31
+ "queued": len(scenario_ids or []),
32
+ "scenario_run_ids": ["r1", "r2"],
33
+ "contact_override": contact_override,
34
+ }
35
+
36
+
37
+ @pytest.mark.asyncio
38
+ async def test_no_contact_args_sends_no_override():
39
+ client = FakeRunClient()
40
+ await tools.scenario_bulk_run(client, benchmark_id="bench-1", output_format="json")
41
+ assert client.bulk_kwargs["contact_override"] is None
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_contact_id_builds_override():
46
+ client = FakeRunClient()
47
+ await tools.scenario_bulk_run(
48
+ client, benchmark_id="bench-1", contact_id="c-9", output_format="json"
49
+ )
50
+ assert client.bulk_kwargs["contact_override"] == {
51
+ "mode": "contact",
52
+ "contact_id": "c-9",
53
+ }
54
+
55
+
56
+ @pytest.mark.asyncio
57
+ async def test_contact_email_resolves_then_overrides():
58
+ client = FakeRunClient()
59
+ result = await tools.scenario_bulk_run(
60
+ client,
61
+ benchmark_id="bench-1",
62
+ contact_email="casey@example.com",
63
+ output_format="json",
64
+ )
65
+ assert client.get_or_create_calls == ["casey@example.com"]
66
+ assert client.bulk_kwargs["contact_override"] == {
67
+ "mode": "contact",
68
+ "contact_id": "contact-123",
69
+ }
70
+ assert json.loads(result)["job_id"] == "job-1"
71
+
72
+
73
+ @pytest.mark.asyncio
74
+ async def test_anonymous_mode():
75
+ client = FakeRunClient()
76
+ await tools.scenario_bulk_run(
77
+ client, benchmark_id="bench-1", anonymous=True, output_format="json"
78
+ )
79
+ assert client.bulk_kwargs["contact_override"] == {"mode": "anonymous"}
80
+
81
+
82
+ @pytest.mark.asyncio
83
+ async def test_explicit_override_wins_over_convenience_args():
84
+ client = FakeRunClient()
85
+ await tools.scenario_bulk_run(
86
+ client,
87
+ benchmark_id="bench-1",
88
+ contact_override={"mode": "contact", "contact_id": "raw"},
89
+ contact_email="ignored@example.com",
90
+ output_format="json",
91
+ )
92
+ # The raw override is used; email resolution is skipped.
93
+ assert client.get_or_create_calls == []
94
+ assert client.bulk_kwargs["contact_override"] == {
95
+ "mode": "contact",
96
+ "contact_id": "raw",
97
+ }
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_v2_handler_threads_contact_email():
102
+ from applied_cli.v2.scenarios import (
103
+ ScenariosBulkRunInput,
104
+ scenarios_bulk_run_handler,
105
+ )
106
+
107
+ client = FakeRunClient()
108
+ result = await scenarios_bulk_run_handler(
109
+ client,
110
+ ScenariosBulkRunInput(benchmark_id="bench-1", contact_email="x@example.com"),
111
+ )
112
+ assert client.bulk_kwargs["contact_override"] == {
113
+ "mode": "contact",
114
+ "contact_id": "contact-123",
115
+ }
116
+ assert result.data["job_id"] == "job-1"
File without changes
File without changes