@capagents/forgeqe 1.2.0 → 1.3.0

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 (75) hide show
  1. package/HOW_TO_USE.md +54 -3
  2. package/README.md +1 -1
  3. package/docs/COST_ARCHITECTURE.md +280 -0
  4. package/docs/feature-test-prompts.md +95 -0
  5. package/docs/user-guide.html +66 -8
  6. package/forgeqe.example.yaml +15 -0
  7. package/package.json +1 -1
  8. package/prompts/brain.md +36 -4
  9. package/prompts/browser-api.md +13 -0
  10. package/prompts/browser-capture.md +2 -2
  11. package/prompts/browser-checks.md +2 -0
  12. package/py/forgeqe_engine/api_step.py +130 -0
  13. package/py/forgeqe_engine/capture.py +53 -2
  14. package/py/forgeqe_engine/checks.py +8 -2
  15. package/py/forgeqe_engine/llm.py +33 -5
  16. package/py/forgeqe_engine/memory.py +70 -2
  17. package/py/forgeqe_engine/observe.py +123 -0
  18. package/py/forgeqe_engine/replay.py +55 -2
  19. package/py/forgeqe_engine/service.py +177 -5
  20. package/src/api/fromSteps.ts +111 -0
  21. package/src/capabilities/registry.ts +19 -5
  22. package/src/cases/ado.ts +1 -1
  23. package/src/cases/journey.ts +61 -0
  24. package/src/cases/quality.ts +3 -1
  25. package/src/cases/readiness.ts +35 -6
  26. package/src/cases/save.ts +241 -48
  27. package/src/cases/types.ts +11 -0
  28. package/src/cli/help.ts +8 -0
  29. package/src/cli/index.ts +64 -1
  30. package/src/code/coverage.ts +22 -3
  31. package/src/code/decisions.ts +73 -0
  32. package/src/code/find.ts +3 -1
  33. package/src/code/graph/graph.ts +33 -2
  34. package/src/code/handoff.ts +20 -0
  35. package/src/code/loop.ts +27 -1
  36. package/src/code/notes.ts +48 -1
  37. package/src/code/prompt.ts +31 -9
  38. package/src/code/protect.ts +103 -0
  39. package/src/code/replay.ts +33 -0
  40. package/src/code/review.ts +2 -0
  41. package/src/code/runtime.ts +35 -3
  42. package/src/code/shortlist.ts +309 -0
  43. package/src/code/testrun.ts +3 -1
  44. package/src/code/traceability.ts +2 -0
  45. package/src/commands.ts +6 -1
  46. package/src/config.ts +46 -0
  47. package/src/engine/bridge.ts +47 -5
  48. package/src/mcp/casetools.ts +107 -11
  49. package/src/mcp/commands.ts +74 -20
  50. package/src/mcp/packtools.ts +187 -0
  51. package/src/mcp/tools.ts +238 -29
  52. package/src/pack/collect.ts +251 -0
  53. package/src/pack/index.ts +134 -0
  54. package/src/pack/init.ts +45 -0
  55. package/src/pack/pack.ts +327 -0
  56. package/src/pack/pipeline.ts +373 -0
  57. package/src/pack/run.ts +155 -0
  58. package/src/registry/db.ts +249 -0
  59. package/src/registry/index.ts +72 -0
  60. package/src/registry/resolve.ts +57 -0
  61. package/src/registry/tier0.ts +81 -0
  62. package/src/report/data.ts +7 -0
  63. package/src/report/html.ts +14 -0
  64. package/src/report/template.html +52 -48
  65. package/src/report/usage.ts +113 -0
  66. package/src/run.ts +32 -2
  67. package/src/suite/report.ts +22 -4
  68. package/src/suite/run.ts +13 -5
  69. package/src/summary.ts +6 -0
  70. package/src/tokenomics/budget.ts +172 -0
  71. package/src/tokenomics/ledger.ts +287 -0
  72. package/src/tui/App.tsx +90 -2
  73. package/src/tui/inspector.tsx +40 -35
  74. package/src/tui/transcript.tsx +5 -4
  75. package/src/types.ts +11 -2
@@ -15,6 +15,7 @@ import os
15
15
  import re
16
16
  import time
17
17
  from dataclasses import dataclass, field
18
+ from datetime import datetime, timezone
18
19
  from pathlib import Path
19
20
  from typing import Any, Callable, Dict, List, Optional, Set, Tuple
20
21
 
@@ -25,6 +26,7 @@ from .memory import READ_ONLY_ACTIONS, MemoryStore, compact_step, flow_step, pag
25
26
  from .prompts import CONVENTIONS, text as prompt_text, use_prompts
26
27
  from .report import CASE_REPORT_INSTRUCTION, CaseReport, build_timeline, case_report_from_history
27
28
  from .replay import ReplayOutcome, handoff_task, replay_flow
29
+ from .observe import check_key, checkable, checks_for_flow
28
30
  from .settings import (
29
31
  _truthy_env,
30
32
  _browser_use_agent_tuning_kwargs,
@@ -107,6 +109,9 @@ class RunRequest:
107
109
  logins: List[Dict[str, Any]] = field(default_factory=list)
108
110
  # Number of steps in the test case this goal came from; the agent then reports a verdict for each.
109
111
  case_steps: int = 0
112
+ # The test case and a key per step (changes when the step is reworded): an edited case replays its unchanged steps.
113
+ case_id: str = ""
114
+ case_step_keys: List[str] = field(default_factory=list)
110
115
  # Step screenshots for the report: "all", "failures", or "off".
111
116
  screenshots: str = "all"
112
117
  # Accessibility checks on each page visited, for the report.
@@ -139,6 +144,8 @@ class RunRequest:
139
144
  return cls(
140
145
  logins=[x for x in (d.get("logins") or []) if isinstance(x, dict)],
141
146
  case_steps=int(d["case_steps"]) if str(d.get("case_steps") or "").isdigit() else 0,
147
+ case_id=str(d.get("case_id") or ""),
148
+ case_step_keys=[str(k) for k in (d.get("case_step_keys") or []) if str(k)],
142
149
  screenshots=str(d.get("screenshots") or "all").lower(),
143
150
  accessibility=d.get("accessibility") is not False,
144
151
  overlay=d.get("overlay") is not False,
@@ -395,6 +402,41 @@ def serialize_browser_step_payload(browser_state: Any, agent_output: Any, n_step
395
402
 
396
403
 
397
404
  REPLAY_MODES = ("verify", "trust", "off")
405
+ # checks_first (docs/COST_ARCHITECTURE.md): every Nth replay of a flow is still checked by the agent, so a check that
406
+ # passes when it should not is caught; and a flow not passed for this long is checked by the agent again.
407
+ CANARY_EVERY = 10
408
+ MAX_FLOW_AGE_DAYS = 30
409
+
410
+
411
+ def _int(raw: Any, default: int) -> int:
412
+ try:
413
+ n = int(raw)
414
+ except (TypeError, ValueError):
415
+ return default
416
+ return n if n >= 0 else default
417
+
418
+
419
+ def judged_by_checks(flow: Dict[str, Any], replay: Any, mem: "MemoryRun") -> Optional[Dict[str, Any]]:
420
+ """
421
+ After a complete replay of an exact flow: whether its recorded checks decide the run with no model. ok: every
422
+ check was found on the page again. Otherwise `why` says what leaves it to the agent (today's verify path).
423
+ """
424
+ why = checkable(flow)
425
+ if why:
426
+ return {"ok": False, "why": why}
427
+ if len(replay.checked) < len(flow.get("checks") or []) or not all(c.get("holds_now") for c in replay.checked):
428
+ return {"ok": False, "why": "not every recorded check was found on the page again"}
429
+ replays = int(flow.get("replays") or 0) + 1
430
+ if mem.canary_every and replays % mem.canary_every == 0:
431
+ return {"ok": False, "why": f"replay {replays} of this flow: every {mem.canary_every}th is checked by the agent too"}
432
+ last = str(flow.get("last_success") or "")
433
+ try:
434
+ age = (datetime.now(timezone.utc) - datetime.fromisoformat(last.replace("Z", "+00:00"))).days if last else None
435
+ except ValueError:
436
+ age = None
437
+ if age is None or age > mem.max_age_days:
438
+ return {"ok": False, "why": f"the flow last passed more than {mem.max_age_days} days ago" if age is not None else "the flow has no recorded pass"}
439
+ return {"ok": True, "why": ""}
398
440
 
399
441
 
400
442
  @dataclass
@@ -418,6 +460,59 @@ class MemoryRun:
418
460
  used_checkpoint: str = ""
419
461
  # Actions that worked without changing the page (dead clicks, read-only scripts): not part of the flow.
420
462
  no_effect: List[str] = field(default_factory=list)
463
+ # The test case this run is for, and a key per case step.
464
+ case_id: str = ""
465
+ case_keys: List[str] = field(default_factory=list)
466
+ # Case step number -> flow steps done when its expected result was recorded (the end of that step).
467
+ check_at: Dict[int, int] = field(default_factory=dict)
468
+ # Ends of the case steps a replay already did, carried over from the flow that was replayed.
469
+ carried_ends: List[Optional[int]] = field(default_factory=list)
470
+ # registry.mode on (docs/COST_ARCHITECTURE.md): the flow keeps its checks and a replay is judged by them.
471
+ checks_first: bool = False
472
+ # The checks the replayed flow had, and those the agent recorded this run with the flow step each came after.
473
+ flow_checks: List[Dict[str, Any]] = field(default_factory=list)
474
+ recorded: List[Dict[str, Any]] = field(default_factory=list)
475
+ check_pos: Dict[str, int] = field(default_factory=dict)
476
+ check_text: Dict[str, Any] = field(default_factory=dict)
477
+ canary_every: int = CANARY_EVERY
478
+ max_age_days: int = MAX_FLOW_AGE_DAYS
479
+
480
+ def checks_to_save(self, status: str) -> Optional[List[Dict[str, Any]]]:
481
+ """The checks the flow keeps after this run; None leaves the flow's as they are."""
482
+ if not self.checks_first or status != "complete":
483
+ return None
484
+ new = checks_for_flow(self.recorded, self.check_pos)
485
+ if not self.llm_steps:
486
+ # A replay judged by its checks: the same steps, so the same checks.
487
+ return list(self.flow_checks) if self.flow_checks else None
488
+ # Checks from the replayed part still stand where the agent did not record them again.
489
+ done = len(self.replay.done) if self.replay else 0
490
+ keys = {check_key(c) for c in new}
491
+ carried = [c for c in self.flow_checks if 0 < int(c.get("at") or 0) <= done and check_key(c) not in keys]
492
+ return carried + new
493
+
494
+ def note_checks(self, checks: List[Dict[str, Any]]) -> None:
495
+ """After an agent step: each case step whose check was just recorded ends at the flow as it is now."""
496
+ self.recorded = checks
497
+ for c in checks:
498
+ key = check_key(c)
499
+ # A check recorded again (the same expected result, new text) moves to where it was recorded last.
500
+ if key and self.check_text.get(key) != c.get("text"):
501
+ self.check_pos[key] = len(self.steps)
502
+ self.check_text[key] = c.get("text")
503
+ for c in checks:
504
+ n = c.get("step")
505
+ if isinstance(n, int) and 0 < n <= len(self.case_keys) and n not in self.check_at:
506
+ self.check_at[n] = len(self.steps)
507
+
508
+ def case_ends(self) -> List[Optional[int]]:
509
+ ends: List[Optional[int]] = [None] * len(self.case_keys)
510
+ for i, e in enumerate(self.carried_ends[: len(ends)]):
511
+ ends[i] = e
512
+ for n, at in self.check_at.items():
513
+ if ends[n - 1] is None:
514
+ ends[n - 1] = at
515
+ return ends
421
516
 
422
517
  def checkpoint(self) -> Optional[Dict[str, Any]]:
423
518
  """Save the steps that worked so far and the page memory learned so far, so a run cut off here can be picked up."""
@@ -432,6 +527,12 @@ class MemoryRun:
432
527
  return data
433
528
 
434
529
  def add(self, step: Dict[str, Any], agent_step: int = 0) -> None:
530
+ # A navigate redone at once (a wrong URL, then the right one) leaves only the one that stood: the flow, its
531
+ # replay, and the test then never open the page that was a mistake.
532
+ if step.get("action") == "navigate" and self.steps and self.steps[-1].get("action") == "navigate":
533
+ self.steps[-1] = step
534
+ self.step_of[-1] = agent_step
535
+ return
435
536
  self.steps.append(step)
436
537
  self.step_of.append(agent_step)
437
538
 
@@ -441,7 +542,14 @@ class MemoryRun:
441
542
  if not folder:
442
543
  return None
443
544
  mode = str((req.memory or {}).get("replay") or "verify").lower()
444
- return cls(MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url)
545
+ mem = req.memory or {}
546
+ return cls(
547
+ MemoryStore(folder), mode if mode in REPLAY_MODES else "verify", req.goal, start_url,
548
+ case_id=req.case_id, case_keys=list(req.case_step_keys) if req.case_id else [],
549
+ checks_first=bool(mem.get("checks_first")),
550
+ canary_every=_int(mem.get("canary_every"), CANARY_EVERY),
551
+ max_age_days=_int(mem.get("max_age_days"), MAX_FLOW_AGE_DAYS),
552
+ )
445
553
 
446
554
  def finish(self, out: Dict[str, Any]) -> Dict[str, Any]:
447
555
  self.finished = True
@@ -464,6 +572,8 @@ class MemoryRun:
464
572
  replayed=len(self.replay.done) if self.replay else 0,
465
573
  llm_steps=self.llm_steps,
466
574
  resume=self.resume if status == "fail" else None,
575
+ case={"id": self.case_id, "keys": self.case_keys, "ends": self.case_ends()} if self.case_id and self.case_keys else None,
576
+ checks=self.checks_to_save(status),
467
577
  )
468
578
  except Exception as ex:
469
579
  logger.warning("memory: could not save the flow: %s", ex)
@@ -488,6 +598,11 @@ class MemoryRun:
488
598
  info["repaired"] = {"step": self.replay.stopped_at, "reason": self.replay.reason, "agent_steps": self.llm_steps}
489
599
  if self.replay and self.replay.heals:
490
600
  info["heals"] = self.replay.heals
601
+ if self.replay and self.replay.checked:
602
+ info["checked"] = len(self.replay.checked)
603
+ info["checks_held"] = sum(1 for c in self.replay.checked if c.get("holds_now"))
604
+ if self.replay and self.replay.skipped_reads:
605
+ info["skipped_reads"] = self.replay.skipped_reads
491
606
  if self.resume and status == "fail":
492
607
  info["resume"] = {
493
608
  "steps": len(self.resume["steps"]),
@@ -521,6 +636,14 @@ def _plan_memory_actions(
521
636
  if not name:
522
637
  continue
523
638
  params = action[name] if isinstance(action[name], dict) else {}
639
+ # The URL the task gave, where the model added a slash: what was opened (see capture.install_effects), recorded.
640
+ if name in ("navigate", "api_request") and params.get("url"):
641
+ from .capture import exact_task_url, task_urls
642
+
643
+ fixed = exact_task_url(str(params["url"]), task_urls(getattr(mem, "goal", "") or ""))
644
+ if fixed:
645
+ line = line.replace(str(params["url"]), fixed)
646
+ params = {**params, "url": fixed}
524
647
  target = None
525
648
  if params.get("index") is not None:
526
649
  try:
@@ -606,6 +729,8 @@ def _record_memory_results(
606
729
  step["overlay"] = target["overlay"]
607
730
  if name == "evaluate" and eff.get("effect") is True:
608
731
  step["scripted"] = True
732
+ if name == "api_request" and eff.get("api"):
733
+ step["api"] = {k: v for k, v in eff["api"].items() if k in ("status", "final_url", "redirected", "snippet", "csrf_sent")}
609
734
  if note:
610
735
  step["note"] = note
611
736
  mem.add(step, agent_step)
@@ -756,7 +881,8 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
756
881
 
757
882
  from browser_use import Agent, Browser, Tools
758
883
 
759
- from .capture import CAPTURE_SYSTEM_MESSAGE, install_effects, register_click_selector
884
+ from .api_step import API_SYSTEM_MESSAGE, register_api_request
885
+ from .capture import CAPTURE_SYSTEM_MESSAGE, correct_task_urls, install_effects, register_click_selector, task_urls
760
886
  from .checks import CHECKS_SYSTEM_MESSAGE, register_checks
761
887
  from .skills import choose_skills, skills_block
762
888
 
@@ -771,7 +897,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
771
897
  except Exception as e:
772
898
  return {"success": False, "error": str(e), "summary": ""}
773
899
  llm_calls: List[Tuple[float, float]] = []
774
- _time_llm_calls(llm, llm_calls)
900
+ _time_llm_calls(llm, llm_calls, lambda c: emit({"type": "call", **c}))
775
901
  use_prompts(req.prompts)
776
902
  diag = Diagnostics(accessibility=req.accessibility, network=req.network, axe=req.axe or None, vitals=req.vitals, visual=req.visual)
777
903
 
@@ -875,6 +1001,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
875
1001
 
876
1002
  async def _on_new_step(browser_state: Any, agent_output: Any, n_steps: int) -> None:
877
1003
  payload = serialize_browser_step_payload(browser_state, agent_output, n_steps + replay_offset)
1004
+ correct_task_urls(payload, captured.get("task_urls"))
878
1005
  collected_steps.append(payload)
879
1006
  _evaluate_control_loop()
880
1007
  emit({"type": "step", **{k: v for k, v in payload.items() if k not in ("actions", "boxes")}})
@@ -902,6 +1029,13 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
902
1029
  mem.used_checkpoint = str(checkpoint.get("run_id") or "")
903
1030
  elif saved and isinstance(saved.get("resume"), dict) and saved["resume"].get("steps"):
904
1031
  flow, kind = saved["resume"], "resume"
1032
+ elif mem.case_id and mem.case_keys:
1033
+ # The test case was edited (its goal is new): replay its steps before the first one that changed.
1034
+ earlier = mem.store.load_case_flow(mem.case_id)
1035
+ if earlier and str(earlier.get("start_url") or "") == (normalized_initial or ""):
1036
+ done, end = MemoryStore.case_prefix(earlier, mem.case_keys)
1037
+ if done:
1038
+ flow, kind = {**earlier, "steps": earlier["steps"][:end], "case_steps_done": done}, "prefix"
905
1039
  if flow and kind and flow.get("steps"):
906
1040
  if kind == "interrupted":
907
1041
  replay_text = (
@@ -914,6 +1048,11 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
914
1048
  + (f" ({flow['run_id']})" if flow.get("run_id") else "")
915
1049
  + (f", which went wrong at step {flow['failed_at']}" if flow.get("failed_at") else "")
916
1050
  )
1051
+ elif kind == "prefix":
1052
+ replay_text = (
1053
+ f"The test case changed after step {flow['case_steps_done']}: replaying the {len(flow['steps'])} recorded "
1054
+ f"steps that did steps 1 to {flow['case_steps_done']}, which did not change"
1055
+ )
917
1056
  else:
918
1057
  replay_text = (
919
1058
  f"Replaying {len(flow['steps'])} recorded steps from memory "
@@ -922,13 +1061,19 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
922
1061
  emit({"type": "memory", "text": replay_text})
923
1062
  await browser.start()
924
1063
  await diag.start(browser)
1064
+ mem.flow_checks = [c for c in (flow.get("checks") or []) if isinstance(c, dict)] if mem.checks_first else []
925
1065
  replay = await replay_flow(
926
- flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay
1066
+ flow, browser=browser, store=mem.store, emit=emit, cancel_event=cancel_event, on_step=lambda: diag.sample(browser), overlay=overlay,
1067
+ checks_first=mem.checks_first and kind == "exact",
927
1068
  )
928
1069
  mem.replay = replay
929
1070
  for done_step in replay.done:
930
1071
  mem.add(done_step)
931
1072
  replay_offset = replay.emitted
1073
+ if replay.complete and kind in ("exact", "prefix"):
1074
+ # The case steps the replay did end where they ended in the recorded run.
1075
+ known = list(flow.get("case_step_ends") or [])
1076
+ mem.carried_ends = known[: int(flow.get("case_steps_done") or len(known))]
932
1077
  if replay.complete and kind == "exact":
933
1078
  emit({"type": "memory", "text": f"Replayed all {len(replay.done)} steps without the LLM"})
934
1079
  elif replay.complete:
@@ -940,6 +1085,30 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
940
1085
  out = {"success": False, "error": err, "summary": err, "step_events": collected_steps, "cancelled": True}
941
1086
  _save(out, "cancelled")
942
1087
  return out
1088
+ judged = judged_by_checks(flow, replay, mem) if replay.complete and kind == "exact" and mem.checks_first and mem.mode == "verify" else None
1089
+ if judged and judged["ok"]:
1090
+ held = replay.checked
1091
+ summary = f"Replayed all {len(replay.done)} recorded steps and found all {len(held)} recorded checks on the page again, without calling the LLM."
1092
+ if flow.get("summary"):
1093
+ summary += f"\n\nResult of the run that recorded them: {flow['summary']}"
1094
+ emit({"type": "memory", "text": f"All {len(held)} recorded checks hold: passed without the LLM"})
1095
+ out = {
1096
+ "success": True,
1097
+ "outcome": "passed",
1098
+ "summary": summary,
1099
+ "result_markdown": f"### Task\n\n{goal}\n\n---\n\n## Result\n\n{summary}",
1100
+ "session_id": req.session_id,
1101
+ "final_url": replay.url,
1102
+ "usage": {"input": 0, "output": 0},
1103
+ "steps": replay.emitted,
1104
+ "step_events": collected_steps,
1105
+ "checks": [{k: v for k, v in c.items() if k in ("expected", "text", "url", "step")} for c in held],
1106
+ "browser": await browser_profile(browser, browser_kw, provider=browser_provider, remote=browser_remote),
1107
+ }
1108
+ _save(out, "memory_checks")
1109
+ return out
1110
+ if judged and not judged["ok"]:
1111
+ emit({"type": "memory", "text": f"The agent checks the result: {judged['why']}"})
943
1112
  if replay.complete and mem.mode == "trust" and kind == "exact":
944
1113
  summary = f"Replayed all {len(replay.done)} recorded steps from memory without calling the LLM."
945
1114
  if flow.get("summary"):
@@ -961,7 +1130,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
961
1130
  handoff = handoff_task(goal, flow, replay, kind=kind)
962
1131
  emit({"type": "memory", "text": "The agent checks the result" if replay.complete and kind == "exact" else "The agent takes over from here"})
963
1132
 
964
- extra_parts: List[str] = [prompt_text("browser-checks", CHECKS_SYSTEM_MESSAGE), prompt_text("browser-capture", CAPTURE_SYSTEM_MESSAGE)]
1133
+ extra_parts: List[str] = [prompt_text("browser-checks", CHECKS_SYSTEM_MESSAGE), prompt_text("browser-capture", CAPTURE_SYSTEM_MESSAGE), prompt_text("browser-api", API_SYSTEM_MESSAGE)]
965
1134
  login_block = build_default_browser_login_system_extension()
966
1135
  if login_block:
967
1136
  extra_parts.append(login_block)
@@ -1000,7 +1169,9 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
1000
1169
  )
1001
1170
  agent_tools = Tools()
1002
1171
  register_checks(agent_tools, recorded_checks)
1172
+ captured["task_urls"] = task_urls(req.goal)
1003
1173
  register_click_selector(agent_tools, captured)
1174
+ register_api_request(agent_tools, captured)
1004
1175
  install_effects(agent_tools, action_effects, captured)
1005
1176
  agent_kw["tools"] = agent_tools
1006
1177
  agent_kw.update(_browser_use_agent_tuning_kwargs())
@@ -1121,6 +1292,7 @@ async def run_browser_task(req: RunRequest, emit: Emit, cancel_event: asyncio.Ev
1121
1292
  mem, pending_actions, results, mem.llm_steps, agent_note(getattr(last, "model_output", None)),
1122
1293
  effects=list(action_effects), page_url=step_url["url"],
1123
1294
  )
1295
+ mem.note_checks(recorded_checks)
1124
1296
  try:
1125
1297
  point = mem.checkpoint()
1126
1298
  if point:
@@ -0,0 +1,111 @@
1
+ import { existsSync, readFileSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import YAML from "yaml"
5
+
6
+ import type { AppConfig } from "../config.ts"
7
+ import { scenarioFromRun } from "./scenario.ts"
8
+ import { saveSet } from "./store.ts"
9
+ import { contractsFromTraffic, readNetwork, type NetworkCall } from "./traffic.ts"
10
+ import type { ContractSet } from "./types.ts"
11
+
12
+ /**
13
+ * The API steps a browser run made (api_request, in the browser's session) kept in ForgeQE's own API format: a
14
+ * contract set with one scenario that makes the calls in the run's order, carrying values from one answer to the next.
15
+ * It runs again without a browser (forgeqe api run, or ForgeQE's agent), and the API tools plan from it.
16
+ */
17
+
18
+ type ApiStep = { op: "api"; method?: string; url?: string; body?: string; content_type?: string; csrf?: string; status?: number; final_url?: string; snippet?: string }
19
+
20
+ export type SavedApiSteps = { id: string; dir: string; file: string; calls: number }
21
+
22
+ function apiSteps(runDir: string): ApiStep[] {
23
+ const file = path.join(runDir, "workflow.yaml")
24
+ if (!existsSync(file)) return []
25
+ try {
26
+ const wf = YAML.parse(readFileSync(file, "utf8")) as { steps?: { op?: string }[] }
27
+ return (wf?.steps || []).filter((s): s is ApiStep => s?.op === "api" && typeof (s as ApiStep).url === "string")
28
+ } catch {
29
+ return []
30
+ }
31
+ }
32
+
33
+ const samePath = (a: string, b: string) => {
34
+ try {
35
+ const x = new URL(a)
36
+ const y = new URL(b)
37
+ return x.host === y.host && x.pathname.replace(/\/$/, "") === y.pathname.replace(/\/$/, "")
38
+ } catch {
39
+ return a === b
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Each API step with the call network.json recorded for it (its headers and the whole answer), in order; a step the
45
+ * capture did not see (capture off, or over its limit) is kept from what the step itself recorded.
46
+ */
47
+ export function callsForSteps(steps: ApiStep[], network: NetworkCall[]): NetworkCall[] {
48
+ const used = new Set<NetworkCall>()
49
+ const ordered = [...network].sort((a, b) => a.t - b.t)
50
+ return steps.map((s, i) => {
51
+ const method = (s.method || "GET").toUpperCase()
52
+ const hit = ordered.find((c) => !used.has(c) && c.method.toUpperCase() === method && samePath(c.url, s.url!))
53
+ if (hit) {
54
+ used.add(hit)
55
+ return hit
56
+ }
57
+ return {
58
+ t: i,
59
+ method,
60
+ url: s.url!,
61
+ type: "Fetch",
62
+ request: { ...(s.content_type ? { headers: { "content-type": s.content_type } } : {}), ...(s.body ? { body: s.body } : {}) },
63
+ response: { status: s.status || 0, ...(s.snippet ? { body: s.snippet } : {}) },
64
+ }
65
+ })
66
+ }
67
+
68
+ /** Saves the run's API steps as a contract set with their scenario; null when the run made no API step. */
69
+ /** "case-ado-2287", or "flow-" and a short hash of the goal: stable across runs of the same case or flow. */
70
+ export function setIdFor(caseId: string | undefined, goal: string): string {
71
+ if (caseId) return `case-${caseId.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
72
+ let h = 0
73
+ for (const ch of goal) h = (h * 31 + ch.charCodeAt(0)) | 0
74
+ return `flow-${(h >>> 0).toString(36)}`
75
+ }
76
+
77
+ export function saveApiSteps(config: AppConfig, runDir: string, opts: { caseId?: string; caseTitle?: string } = {}): SavedApiSteps | null {
78
+ const steps = apiSteps(runDir)
79
+ if (!steps.length) return null
80
+ const calls = callsForSteps(steps, readNetwork(runDir)).filter((c) => c.response?.status)
81
+ if (!calls.length) return null
82
+ const { contracts, skipped, baseUrl } = contractsFromTraffic(calls, { thirdParty: true })
83
+ let goal = ""
84
+ try {
85
+ goal = (JSON.parse(readFileSync(path.join(runDir, "run.json"), "utf8")) as { goal?: string }).goal || ""
86
+ } catch {}
87
+ // One set per test case (or per flow), updated by each run: the latest calls, not a new copy every time.
88
+ const id = setIdFor(opts.caseId, goal || path.basename(runDir))
89
+ for (const c of contracts) c.source.ref = path.basename(runDir)
90
+ const set: ContractSet = {
91
+ version: 1,
92
+ id,
93
+ name: opts.caseId ? `API steps of ${opts.caseId}${opts.caseTitle ? `: ${opts.caseTitle}` : ""}` : `API steps of: ${goal.slice(0, 80) || path.basename(runDir)}`,
94
+ baseUrl,
95
+ createdAt: new Date().toISOString(),
96
+ source: { kind: "run", ref: runDir },
97
+ contracts,
98
+ ...(skipped.length ? { skipped } : {}),
99
+ }
100
+ const scenario = scenarioFromRun(calls, set, { goal, runId: path.basename(runDir), thirdParty: true })
101
+ if (scenario) {
102
+ scenario.notes = [
103
+ ...(scenario.notes || []),
104
+ "The run made these calls in the browser's logged-in session. Run without a browser, they need that session: a login step first, or its cookie and CSRF token in api headers.",
105
+ ]
106
+ if (opts.caseId) Object.assign(scenario, { caseId: opts.caseId, ...(opts.caseTitle ? { caseTitle: opts.caseTitle } : {}) })
107
+ set.scenarios = [scenario]
108
+ }
109
+ const saved = saveSet(config, set)
110
+ return { id, dir: saved.dir, file: saved.file, calls: calls.length }
111
+ }
@@ -111,19 +111,20 @@ export const CAPABILITIES: Capability[] = [
111
111
  area: "Test cases",
112
112
  title: "Check and improve test cases",
113
113
  summary:
114
- "Checks test cases before they run (vague or missing expected results, missing test data, placeholders, duplicates) against the story, feature, and epic they test, then rewrites Azure DevOps cases once the user agrees.",
114
+ "Checks test cases before they run (vague or missing expected results, missing test data, placeholders, duplicates) against the story, feature, and epic they test, then rewrites Azure DevOps or Jira cases in place once the user agrees.",
115
115
  tools: ["check_test_cases", "update_test_case"],
116
116
  examples: ["Are the cases in test plan 12 good enough to run?", "Fix the expected results of case 4512"],
117
- requires: [{ kind: "system", anyOf: ["azure_devops"], optional: true, why: "to rewrite cases in place" }],
117
+ requires: [{ kind: "system", anyOf: ["azure_devops", "jira"], optional: true, why: "to rewrite cases in place" }],
118
118
  docs: [{ doc: "HOW_TO_USE.md", heading: "Checking test cases before they run" }],
119
119
  },
120
120
  {
121
121
  id: "write-test-cases",
122
122
  area: "Test cases",
123
123
  title: "Write test cases from a story",
124
- summary: "Writes test cases from a story's acceptance criteria and saves them in Azure DevOps or Jira, linked to the story.",
125
- tools: ["save_test_cases"],
126
- examples: ["Write test cases for story 1234 and save them"],
124
+ summary:
125
+ "Writes test cases from a story's acceptance criteria and saves them in Azure DevOps or Jira, linked to the story. Reads the cases already linked first, so asking again adds only what is missing and updates cases in place instead of duplicating them; plans end-to-end journeys from the existing cases.",
126
+ tools: ["story_test_cases", "save_test_cases", "e2e_journey"],
127
+ examples: ["Write test cases for story 1234 and save them", "Build an end-to-end journey from story 1234's cases"],
127
128
  requires: [{ kind: "system", anyOf: ["azure_devops", "jira"] }],
128
129
  docs: [{ doc: "HOW_TO_USE.md", heading: "Where cases come from" }],
129
130
  },
@@ -185,6 +186,19 @@ export const CAPABILITIES: Capability[] = [
185
186
  ],
186
187
  docs: [{ doc: "HOW_TO_USE.md", heading: "Finding and running existing tests" }],
187
188
  },
189
+ {
190
+ id: "regression-packs",
191
+ area: "Automated tests",
192
+ title: "Regression packs for a release",
193
+ summary: "Builds a regression pack from what goes into a release (an Azure DevOps sprint or work items, a commit range, pull requests, or changes read from any connected tool): the tests those changes can break, each with why, plus risk-area and smoke tests, and the changes no test covers. Runs it locally, on TestMu AI, or in a pipeline, and compares each run with the last.",
194
+ tools: ["build_regression_pack", "run_regression_pack", "list_regression_packs"],
195
+ examples: ["Build a regression pack for sprint 12 and run it on TestMu", "Which tests should we run for the PRs merged this week?"],
196
+ requires: [
197
+ { kind: "input", what: "a repository with tests" },
198
+ { kind: "system", anyOf: ["azure_devops", "jira"], optional: true, why: "to read a sprint's or release's work items" },
199
+ ],
200
+ docs: [{ doc: "HOW_TO_USE.md", heading: "Regression packs for a release" }],
201
+ },
188
202
  {
189
203
  id: "flaky-tests",
190
204
  area: "Automated tests",
package/src/cases/ado.ts CHANGED
@@ -192,7 +192,7 @@ const xml = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "", pr
192
192
  type Node = Record<string, unknown> & { ":@"?: Record<string, string> }
193
193
 
194
194
  /** Microsoft.VSTS.TCM.Steps: `<step>` action/expected pairs in order, with shared steps (`<compref ref>`) inlined. */
195
- async function parseSteps(source: string, base: string, auth: string, depth = 0): Promise<CaseStep[]> {
195
+ export async function parseSteps(source: string, base: string, auth: string, depth = 0): Promise<CaseStep[]> {
196
196
  if (!source.trim()) return []
197
197
  const steps: CaseStep[] = []
198
198
  const walk = async (nodes: Node[]) => {
@@ -0,0 +1,61 @@
1
+ import { stepText, type CaseStep } from "./types.ts"
2
+
3
+ /** A test case as a journey uses it. */
4
+ export type JourneyCase = { id: string; title: string; url?: string; startUrl?: string; steps: CaseStep[] }
5
+
6
+ /** One case inside a journey: its steps are `from`-`to` of the journey (1-based), after the shared steps. */
7
+ export type JourneySection = { caseId: string; title: string; from: number; to: number; negative: boolean }
8
+
9
+ export type Journey = { startUrl?: string; shared: CaseStep[]; steps: CaseStep[]; sections: JourneySection[]; saved: number }
10
+
11
+ /** Steps that look like a negative path: a refusal, an error, or one that ends the session or removes data. */
12
+ const NEGATIVE = /\b(invalid|incorrect|wrong|error|denied|refused|rejects?|rejected|fails?|failed|locked|not allowed|cannot|can't|unauthori[sz]ed|forbidden|sign(?:s|ed)? out|log(?:s|ged)? out|delete[sd]?|remove[sd]?)\b/i
13
+
14
+ const key = (s: CaseStep) => `${stepText(s.action)}|${stepText(s.expected)}`
15
+
16
+ function sharedPrefix(cases: JourneyCase[]): number {
17
+ let n = 0
18
+ for (;;) {
19
+ const first = cases[0].steps[n]
20
+ if (!first || cases.some((c) => !c.steps[n] || key(c.steps[n]) !== key(first))) return n
21
+ n++
22
+ }
23
+ }
24
+
25
+ /**
26
+ * Journeys from cases that start the same way: same start page and first step. The steps they all share come once,
27
+ * then each case's own steps follow as a section; cases that look negative go last, since they may end the session.
28
+ * A case that starts like no other stays on its own. The draft keeps the cases' order otherwise: the agent reorders it.
29
+ */
30
+ export function planJourneys(cases: JourneyCase[]): { journeys: Journey[]; alone: JourneyCase[]; stepsSaved: number } {
31
+ const groups = new Map<string, JourneyCase[]>()
32
+ for (const c of cases) {
33
+ if (!c.steps.length) continue
34
+ const g = `${c.startUrl || ""}\n${key(c.steps[0])}`
35
+ groups.set(g, [...(groups.get(g) || []), c])
36
+ }
37
+ const journeys: Journey[] = []
38
+ const alone: JourneyCase[] = cases.filter((c) => !c.steps.length)
39
+ for (const group of groups.values()) {
40
+ if (group.length < 2) {
41
+ alone.push(...group)
42
+ continue
43
+ }
44
+ const n = sharedPrefix(group)
45
+ const shared = group[0].steps.slice(0, n)
46
+ const ordered = group
47
+ .map((c) => ({ c, tail: c.steps.slice(n) }))
48
+ .map((x) => ({ ...x, negative: NEGATIVE.test(x.tail.map((s) => `${s.action} ${s.expected || ""}`).join(" ")) }))
49
+ .sort((a, b) => Number(a.negative) - Number(b.negative))
50
+ const steps = [...shared]
51
+ const sections: JourneySection[] = []
52
+ for (const { c, tail, negative } of ordered) {
53
+ // A case that is only the shared steps is covered by them.
54
+ const from = tail.length ? steps.length + 1 : 1
55
+ steps.push(...tail)
56
+ sections.push({ caseId: c.id, title: c.title, from, to: tail.length ? steps.length : n, negative })
57
+ }
58
+ journeys.push({ startUrl: group[0].startUrl, shared, steps, sections, saved: (group.length - 1) * n })
59
+ }
60
+ return { journeys, alone, stepsSaved: journeys.reduce((sum, j) => sum + j.saved, 0) }
61
+ }
@@ -54,7 +54,9 @@ export function checkCase(tc: TestCase): CaseFinding[] {
54
54
  }
55
55
  const withExpected = steps.filter((s) => s.expected?.trim())
56
56
  if (!withExpected.length) {
57
- out.push({ code: "no_expected_results", severity: "blocker", message: "No step has an expected result, so the run cannot pass or fail on anything.", suggestion: "Give at least the steps that matter an expected result the page shows (a message, a value, a page)." })
57
+ // It still runs: each step is done and verified, so the case passes when every step can be done. What it does not
58
+ // have is a result to check, so it is worth fixing, not a reason to refuse it.
59
+ out.push({ code: "no_expected_results", severity: "warning", message: "No step has an expected result: the run passes when every step can be done, and checks no result the page should show.", suggestion: "Give at least the steps that matter an expected result the page shows (a message, a value, a page)." })
58
60
  }
59
61
  steps.forEach((s, i) => {
60
62
  const n = i + 1