@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.
- package/HOW_TO_USE.md +54 -3
- package/README.md +1 -1
- package/docs/COST_ARCHITECTURE.md +280 -0
- package/docs/feature-test-prompts.md +95 -0
- package/docs/user-guide.html +66 -8
- package/forgeqe.example.yaml +15 -0
- package/package.json +1 -1
- package/prompts/brain.md +36 -4
- package/prompts/browser-api.md +13 -0
- package/prompts/browser-capture.md +2 -2
- package/prompts/browser-checks.md +2 -0
- package/py/forgeqe_engine/api_step.py +130 -0
- package/py/forgeqe_engine/capture.py +53 -2
- package/py/forgeqe_engine/checks.py +8 -2
- package/py/forgeqe_engine/llm.py +33 -5
- package/py/forgeqe_engine/memory.py +70 -2
- package/py/forgeqe_engine/observe.py +123 -0
- package/py/forgeqe_engine/replay.py +55 -2
- package/py/forgeqe_engine/service.py +177 -5
- package/src/api/fromSteps.ts +111 -0
- package/src/capabilities/registry.ts +19 -5
- package/src/cases/ado.ts +1 -1
- package/src/cases/journey.ts +61 -0
- package/src/cases/quality.ts +3 -1
- package/src/cases/readiness.ts +35 -6
- package/src/cases/save.ts +241 -48
- package/src/cases/types.ts +11 -0
- package/src/cli/help.ts +8 -0
- package/src/cli/index.ts +64 -1
- package/src/code/coverage.ts +22 -3
- package/src/code/decisions.ts +73 -0
- package/src/code/find.ts +3 -1
- package/src/code/graph/graph.ts +33 -2
- package/src/code/handoff.ts +20 -0
- package/src/code/loop.ts +27 -1
- package/src/code/notes.ts +48 -1
- package/src/code/prompt.ts +31 -9
- package/src/code/protect.ts +103 -0
- package/src/code/replay.ts +33 -0
- package/src/code/review.ts +2 -0
- package/src/code/runtime.ts +35 -3
- package/src/code/shortlist.ts +309 -0
- package/src/code/testrun.ts +3 -1
- package/src/code/traceability.ts +2 -0
- package/src/commands.ts +6 -1
- package/src/config.ts +46 -0
- package/src/engine/bridge.ts +47 -5
- package/src/mcp/casetools.ts +107 -11
- package/src/mcp/commands.ts +74 -20
- package/src/mcp/packtools.ts +187 -0
- package/src/mcp/tools.ts +238 -29
- package/src/pack/collect.ts +251 -0
- package/src/pack/index.ts +134 -0
- package/src/pack/init.ts +45 -0
- package/src/pack/pack.ts +327 -0
- package/src/pack/pipeline.ts +373 -0
- package/src/pack/run.ts +155 -0
- package/src/registry/db.ts +249 -0
- package/src/registry/index.ts +72 -0
- package/src/registry/resolve.ts +57 -0
- package/src/registry/tier0.ts +81 -0
- package/src/report/data.ts +7 -0
- package/src/report/html.ts +14 -0
- package/src/report/template.html +52 -48
- package/src/report/usage.ts +113 -0
- package/src/run.ts +32 -2
- package/src/suite/report.ts +22 -4
- package/src/suite/run.ts +13 -5
- package/src/summary.ts +6 -0
- package/src/tokenomics/budget.ts +172 -0
- package/src/tokenomics/ledger.ts +287 -0
- package/src/tui/App.tsx +90 -2
- package/src/tui/inspector.tsx +40 -35
- package/src/tui/transcript.tsx +5 -4
- 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
|
-
|
|
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 .
|
|
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:
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|
package/src/cases/quality.ts
CHANGED
|
@@ -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
|
-
|
|
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
|