@capagents/forgeqe 1.1.4 → 1.1.6

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 (65) hide show
  1. package/HOW_TO_USE.md +64 -17
  2. package/README.md +2 -1
  3. package/browserstack.example.yaml +14 -2
  4. package/docs/user-guide.html +78 -23
  5. package/forgeqe.example.yaml +18 -5
  6. package/package.json +2 -1
  7. package/prompts/README.md +1 -0
  8. package/prompts/brain.md +8 -1
  9. package/prompts/defect-rca.md +43 -0
  10. package/prompts/test-agent.md +13 -2
  11. package/prompts/test-reviewer.md +8 -3
  12. package/prompts/test-rules.md +10 -4
  13. package/py/forgeqe_engine/browser.py +1 -1
  14. package/skills/dotnet-nunit-playwright/SKILL.md +2 -1
  15. package/skills/playwright-typescript/SKILL.md +2 -1
  16. package/src/brain/index.ts +1 -1
  17. package/src/capabilities/registry.ts +34 -6
  18. package/src/cases/bugs.ts +209 -22
  19. package/src/cases/defectHistory.ts +193 -0
  20. package/src/cases/defects.ts +255 -0
  21. package/src/cases/github.ts +162 -0
  22. package/src/cases/goal.ts +1 -1
  23. package/src/cases/index.ts +23 -4
  24. package/src/cases/predict.ts +372 -0
  25. package/src/cases/rca.ts +258 -0
  26. package/src/cases/readiness.ts +38 -1
  27. package/src/cases/repro.ts +202 -0
  28. package/src/cases/types.ts +8 -1
  29. package/src/cli/help.ts +8 -2
  30. package/src/cli/index.ts +36 -7
  31. package/src/code/graph/graph.ts +518 -0
  32. package/src/code/graph/locators.ts +398 -0
  33. package/src/code/graph/parse.ts +439 -0
  34. package/src/code/graph/tools.ts +113 -0
  35. package/src/code/handoff.ts +93 -18
  36. package/src/code/loop.ts +30 -5
  37. package/src/code/profile.ts +14 -3
  38. package/src/code/replay.ts +1 -1
  39. package/src/code/review.ts +22 -0
  40. package/src/code/runtime.ts +8 -1
  41. package/src/config.ts +142 -18
  42. package/src/mcp/attachtools.ts +223 -0
  43. package/src/mcp/commands.ts +1 -1
  44. package/src/mcp/defecttools.ts +106 -0
  45. package/src/mcp/devicetools.ts +125 -0
  46. package/src/mcp/external.ts +42 -1
  47. package/src/mcp/server.ts +3 -1
  48. package/src/mcp/tools.ts +95 -10
  49. package/src/monitor/run.ts +1 -1
  50. package/src/report/data.ts +14 -0
  51. package/src/report/html.ts +15 -3
  52. package/src/report/template.html +44 -43
  53. package/src/run.ts +4 -2
  54. package/src/suite/report.ts +44 -11
  55. package/src/suite/run.ts +163 -16
  56. package/src/tui/App.tsx +153 -94
  57. package/src/tui/components.tsx +27 -23
  58. package/src/tui/inspector.tsx +140 -83
  59. package/src/tui/landing.ts +3 -4
  60. package/src/tui/refine-panel.tsx +102 -52
  61. package/src/tui/shortcuts.tsx +2 -0
  62. package/src/tui/transcript.tsx +32 -2
  63. package/src/types.ts +3 -1
  64. package/src/usage.ts +1 -1
  65. package/testmu.example.yaml +11 -0
@@ -0,0 +1,125 @@
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
2
+ import path from "node:path"
3
+ import YAML from "yaml"
4
+
5
+ import { browserProvider, DEVICE_NAME, loadConfig, type AppConfig, type BrowserDevice, type RemoteBrowserProvider } from "../config.ts"
6
+ import { projectDir } from "../tools.ts"
7
+ import type { McpTool } from "./tools.ts"
8
+
9
+ /**
10
+ * Named remote browsers ("devices") the user describes in conversation: kept under devices: in the provider's YAML
11
+ * file (browser.providers.<provider> in forgeqe.yaml, created when missing), so runs and CI see the same list.
12
+ */
13
+
14
+ /** A device as the agent reads it. */
15
+ export function deviceView(d: BrowserDevice) {
16
+ const r = d.remote
17
+ return {
18
+ name: d.name,
19
+ provider: d.provider,
20
+ browser: r.browser,
21
+ browser_version: r.browserVersion,
22
+ ...(r.platform ? { platform: r.platform } : {}),
23
+ ...(r.os ? { os: r.os } : {}),
24
+ ...(r.osVersion ? { os_version: r.osVersion } : {}),
25
+ }
26
+ }
27
+
28
+ /** The provider's YAML file, from forgeqe.yaml; when there is none, .forgeqe/<provider>.yaml, added to forgeqe.yaml. */
29
+ function providerFile(config: AppConfig, provider: RemoteBrowserProvider): { file: string; added: boolean } {
30
+ const known = config.browser.providerFiles[provider]
31
+ if (known) return { file: known, added: false }
32
+ const configPath = config.configPath
33
+ if (!configPath) throw new Error("There is no forgeqe.yaml here to add a device to: run forgeqe init first.")
34
+ const file = path.join(projectDir(path.dirname(configPath)), `${provider}.yaml`)
35
+ const doc = YAML.parseDocument(readFileSync(configPath, "utf8"))
36
+ if (!YAML.isMap(doc.get("browser"))) doc.set("browser", doc.createNode({}))
37
+ doc.setIn(["browser", "providers", provider], `./${path.relative(path.dirname(configPath), file).split(path.sep).join("/")}`)
38
+ writeFileSync(configPath, doc.toString())
39
+ return { file, added: true }
40
+ }
41
+
42
+ /**
43
+ * Write a change to a provider file and forgeqe.yaml, then load the whole configuration again: a device that does not
44
+ * load (a missing platform, a browser the engine cannot drive) puts both files back and fails, so a bad device never
45
+ * breaks the configuration. The session's configuration takes the new browser settings.
46
+ */
47
+ function applyChange(config: AppConfig, provider: RemoteBrowserProvider, change: (doc: YAML.Document) => void): { file: string } {
48
+ const configPath = config.configPath
49
+ const before = configPath && existsSync(configPath) ? readFileSync(configPath, "utf8") : undefined
50
+ const { file, added } = providerFile(config, provider)
51
+ const old = existsSync(file) ? readFileSync(file, "utf8") : undefined
52
+ const doc = YAML.parseDocument(old ?? `provider: ${provider}\n`)
53
+ change(doc)
54
+ mkdirSync(path.dirname(file), { recursive: true })
55
+ writeFileSync(file, doc.toString())
56
+ try {
57
+ const fresh = loadConfig({ configPath, profile: config.activeProfile })
58
+ Object.assign(config.browser, fresh.browser)
59
+ } catch (err) {
60
+ if (old === undefined) rmSync(file, { force: true })
61
+ else writeFileSync(file, old)
62
+ if (added && configPath && before !== undefined) writeFileSync(configPath, before)
63
+ throw err
64
+ }
65
+ return { file }
66
+ }
67
+
68
+ const str = (v: unknown) => (v === undefined || v === null ? "" : String(v).trim())
69
+
70
+ export const DEVICE_TOOLS: McpTool[] = [
71
+ {
72
+ name: "browser_devices",
73
+ description:
74
+ "List, add or change, and remove named remote browsers (devices) on BrowserStack or TestMu AI/LambdaTest, kept in the provider's YAML file so every later run and CI sees them. When the user describes the devices or browsers to test on, save each one here, then run on them by passing their names as devices to browser_run or run_test_cases. ForgeQE drives remote browsers over the Chrome DevTools Protocol, so a device is Chrome (TestMu AI: Chrome only) or Edge (BrowserStack) on a Windows or macOS version; Safari, Firefox, and real mobile devices are not supported, so say so instead of saving them.",
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ action: { type: "string", enum: ["list", "save", "remove"], description: "list the devices; save adds a device or changes one; remove deletes one" },
79
+ name: { type: "string", description: "Short device name for save/remove, e.g. win11-chrome, mac-sequoia-edge" },
80
+ provider: { type: "string", enum: ["browserstack", "testmu"], description: "For save: the grid the device is on (testmu is TestMu AI / LambdaTest). Needed for a new device." },
81
+ browser: { type: "string", description: "For save: TestMu: Chrome; BrowserStack: chrome or edge" },
82
+ browser_version: { type: "string", description: "For save: a version such as 128, or latest, latest-1 (default latest)" },
83
+ platform: { type: "string", description: "For save on TestMu: the platform, e.g. Windows 11, Windows 10, macOS Sequoia" },
84
+ os: { type: "string", description: "For save on BrowserStack: Windows or OS X" },
85
+ os_version: { type: "string", description: "For save on BrowserStack: 11, 10, Sequoia, Sonoma, ..." },
86
+ },
87
+ required: ["action"],
88
+ },
89
+ async run(args, ctx) {
90
+ const config = ctx.config
91
+ const action = str(args.action) || "list"
92
+ const list = () => ({
93
+ devices: Object.values(config.browser.devices || {}).map(deviceView),
94
+ providers: Object.fromEntries(Object.entries(config.browser.providerFiles).map(([p, f]) => [p, f])),
95
+ })
96
+ if (action === "list") return list()
97
+ const name = str(args.name)
98
+ if (!DEVICE_NAME.test(name)) throw new Error('name is required: a short name such as "win11-chrome" (letters, digits, ".", "_", "-").')
99
+ const existing = config.browser.devices?.[name]
100
+ if (action === "remove") {
101
+ if (!existing) throw new Error(`No device named ${name}.`)
102
+ const { file } = applyChange(config, existing.provider, (doc) => {
103
+ doc.deleteIn(["devices", name])
104
+ const left = doc.get("devices")
105
+ if (YAML.isMap(left) && !left.items.length) doc.delete("devices")
106
+ })
107
+ return { removed: name, file, ...list() }
108
+ }
109
+ if (action !== "save") throw new Error(`Unknown action ${action}: use list, save, or remove.`)
110
+ const asked = str(args.provider)
111
+ const provider = (asked ? browserProvider(asked) : existing?.provider) as RemoteBrowserProvider | "local" | undefined
112
+ if (!provider || provider === "local") throw new Error("provider is required for a new device: browserstack or testmu.")
113
+ if (existing && existing.provider !== provider) throw new Error(`Device ${name} is on ${existing.provider}; remove it first to move it to ${provider}.`)
114
+ const fields: Record<string, string> = {}
115
+ for (const key of ["browser", "browser_version", "platform", "os", "os_version"]) if (str(args[key])) fields[key] = str(args[key])
116
+ if (!existing && !fields.browser) throw new Error("browser is required for a new device: Chrome or Edge.")
117
+ const { file } = applyChange(config, provider, (doc) => {
118
+ if (!doc.get("provider")) doc.set("provider", provider)
119
+ for (const [key, value] of Object.entries(fields)) doc.setIn(["devices", name, key], value)
120
+ })
121
+ const saved = config.browser.devices[name]
122
+ return { saved: deviceView(saved), file, ...list() }
123
+ },
124
+ },
125
+ ]
@@ -1,3 +1,6 @@
1
+ import { spawnSync } from "node:child_process"
2
+
3
+ import { ghAuthToken } from "../cases/github.ts"
1
4
  import type { AppConfig } from "../config.ts"
2
5
 
3
6
  /**
@@ -22,6 +25,15 @@ const ADO_DOMAIN_WORDS: Record<string, string> = {
22
25
  "advanced-security": "security alerts",
23
26
  }
24
27
  export const ROVO_URL = "https://mcp.atlassian.com/v2/mcp"
28
+ export const GITHUB_MCP_URL = "https://api.githubcopilot.com/mcp/"
29
+
30
+ /** The GitHub repository (owner/name) the folder's git remotes point at, or null. */
31
+ export function githubRemote(cwd: string): string | null {
32
+ const res = spawnSync("git", ["config", "--get-regexp", "^remote\\..*\\.url$"], { cwd, encoding: "utf8", timeout: 5000 })
33
+ if (res.status !== 0) return null
34
+ const m = /github\.com[/:]([^/\s]+)\/([^/\s]+?)(?:\.git)?\s*$/m.exec(res.stdout)
35
+ return m ? `${m[1]}/${m[2]}` : null
36
+ }
25
37
  /** Also the time allowed for the first start, when npx or uvx downloads the server. */
26
38
  const TIMEOUT_MS = 120_000
27
39
 
@@ -61,7 +73,12 @@ export function adoOrganization(orgUrl: string): string | null {
61
73
  * server for Jira, and anything under `mcp:`. Credentials stay in the environment: configs refer to them as {env:NAME}.
62
74
  * A system whose server cannot start here is listed in `skipped`; ForgeQE's own API tools still cover it.
63
75
  */
64
- export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJS.ProcessEnv = process.env): ExternalPlan {
76
+ export function externalMcp(
77
+ cfg: AppConfig,
78
+ which: Which = Bun.which,
79
+ env: NodeJS.ProcessEnv = process.env,
80
+ opts: { cwd?: string; ghToken?: () => string | null; remote?: (cwd: string) => string | null } = {},
81
+ ): ExternalPlan {
65
82
  const servers: ExternalServer[] = []
66
83
  const skipped: ExternalPlan["skipped"] = []
67
84
  const { ado, jira, confluence: conf } = cfg.sources
@@ -175,6 +192,30 @@ export function externalMcp(cfg: AppConfig, which: Which = Bun.which, env: NodeJ
175
192
  }
176
193
  }
177
194
 
195
+ // GitHub's hosted MCP server, when the project is on GitHub: sources.github in forgeqe.yaml, or a GitHub remote in the
196
+ // folder. Its tools cost tokens on every request, so a project that is not on GitHub does not get them.
197
+ const gh = cfg.sources.github
198
+ // The folder the agent works in, when the caller names it: a GitHub remote there turns the server on.
199
+ const remote = gh?.mcp === false || cfg.mcp?.github || !opts.cwd ? null : (opts.remote || githubRemote)(opts.cwd)
200
+ if (gh?.mcp !== false && !cfg.mcp?.github && (gh || remote)) {
201
+ const label = "GitHub MCP"
202
+ const token = env[gh?.tokenEnv || "GITHUB_TOKEN"]?.trim() || (opts.ghToken || ghAuthToken)()
203
+ if (gh && gh.apiUrl !== "https://api.github.com") skipped.push({ label, why: "GitHub's hosted server is for github.com; add your Enterprise server's MCP under mcp:" })
204
+ else if (!token) skipped.push({ label, why: `sign in with forgeqe auth login github, or set ${gh?.tokenEnv || "GITHUB_TOKEN"}` })
205
+ else {
206
+ const toolsets = gh?.toolsets?.length ? gh.toolsets : ["repos", "issues", "pull_requests", "actions"]
207
+ const repo = gh?.repo || remote
208
+ servers.push({
209
+ name: "github",
210
+ label,
211
+ about: `GitHub's MCP server (${toolsets.join(", ")})${repo ? `; this project is ${repo}, so use it unless the user names another repository` : ""}. It cannot upload files to issues; ForgeQE's own tools write test results and bugs to GitHub issues.`,
212
+ config: { type: "remote", url: GITHUB_MCP_URL, headers: { Authorization: "Bearer {env:FORGEQE_GITHUB_MCP_TOKEN}", "X-MCP-Toolsets": toolsets.join(",") }, oauth: false, enabled: true, timeout: TIMEOUT_MS },
213
+ secrets: { FORGEQE_GITHUB_MCP_TOKEN: token },
214
+ signIn: env[gh?.tokenEnv || "GITHUB_TOKEN"]?.trim() ? `token in ${gh?.tokenEnv || "GITHUB_TOKEN"}` : "GitHub CLI sign-in",
215
+ })
216
+ }
217
+ }
218
+
178
219
  for (const [name, server] of Object.entries(cfg.mcp || {})) {
179
220
  const at = servers.findIndex((s) => s.name === name)
180
221
  if (at >= 0) servers.splice(at, 1)
package/src/mcp/server.ts CHANGED
@@ -62,9 +62,11 @@ export function createMcpServer(opts: McpServerOptions) {
62
62
  base.emit?.(e)
63
63
  }
64
64
  : undefined
65
+ const callId = `${tool.name}-${String(id)}-${started}`
65
66
  const said = (ok: boolean) => {
66
- if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok })
67
+ if (tool.line && emit) emit({ type: "call", text: tool.line(params.arguments || {}), ok, id: callId })
67
68
  }
69
+ if (tool.line && emit) emit({ type: "call-start", text: tool.line(params.arguments || {}), id: callId })
68
70
  let out: unknown
69
71
  try {
70
72
  out = await tool.run(params.arguments || {}, { ...base, emit, signal: controller.signal })
package/src/mcp/tools.ts CHANGED
@@ -19,17 +19,20 @@ import { browserProvider, type AppConfig } from "../config.ts"
19
19
  import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
20
20
  import { executeRun, runDirectory } from "../run.ts"
21
21
  import { counts } from "../suite/report.ts"
22
- import { runSuite, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
22
+ import { deviceCases, runSuite, suiteParallel, type SuiteEvent, type SuiteResult } from "../suite/run.ts"
23
23
  import { COMMAND_TOOLS, TASK_FILE } from "./commands.ts"
24
24
  import { PR_TOOLS } from "./prtools.ts"
25
25
  import { CASE_TOOLS, checkResult, precheck } from "./casetools.ts"
26
26
  import { REPO_TEST_TOOLS } from "./repotests.ts"
27
+ import { DEFECT_TOOLS } from "./defecttools.ts"
27
28
  import { API_TOOLS } from "./apitools.ts"
28
29
  import { EXPLORE_TOOLS } from "./exploretools.ts"
29
30
  import { QUALITY_TOOLS } from "./qualitytools.ts"
30
31
  import { CAPABILITY_TOOLS } from "./captools.ts"
31
32
  import { NOTIFY_TOOLS } from "./notifytools.ts"
32
33
  import { REPORT_TOOLS } from "./reporttools.ts"
34
+ import { DEVICE_TOOLS } from "./devicetools.ts"
35
+ import { ATTACH_TOOLS } from "./attachtools.ts"
33
36
  import { capabilityIndex } from "../capabilities/render.ts"
34
37
  import { domainNotes } from "../domain.ts"
35
38
  import { readNetwork } from "../api/traffic.ts"
@@ -99,7 +102,9 @@ export type ToolEvent =
99
102
  /** write_report saved a report: its page and its Markdown. */
100
103
  | { type: "report"; title: string; html: string; markdown: string }
101
104
  /** A finished call of a tool that has no card of its own, as one line: "Azure DevOps API · QI-Claims/_apis/build/builds (top 5)". */
102
- | { type: "call"; text: string; ok: boolean }
105
+ | { type: "call"; text: string; ok: boolean; id?: string }
106
+ /** The same call as it starts, so a long one is visible while it runs; its `call` with the same `id` follows. */
107
+ | { type: "call-start"; text: string; id: string }
103
108
 
104
109
  const API_LABEL: Record<string, string> = { azure_devops: "Azure DevOps", jira: "Jira", xray: "Xray", confluence: "Confluence" }
105
110
 
@@ -124,6 +129,8 @@ export type ToolContext = {
124
129
  cwd: string
125
130
  headed?: boolean
126
131
  browserProvider?: AppConfig["browser"]["provider"]
132
+ /** Named devices the user pinned for this session (--devices): runs go to each of them. */
133
+ devices?: string[]
127
134
  maxSteps?: number
128
135
  replay?: ReplayMode
129
136
  memory?: boolean
@@ -190,14 +197,14 @@ const SOURCES_SCHEMA = {
190
197
  properties: {
191
198
  source: {
192
199
  type: "string",
193
- enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "file", "text"],
200
+ enum: ["azure_devops", "jira", "xray", "testrail", "zephyr", "qtest", "github", "file", "text"],
194
201
  description:
195
- "azure_devops: Test Case work items or a Test Plan. jira: issues. xray: Xray Test issues or a Test Plan. testrail: cases, a run, a plan, or a suite. zephyr: Zephyr Scale test cases, a test cycle, or a folder. qtest: qTest test cases, a test cycle, or a suite. file: .txt/.md/.csv/.yaml/.json/.feature files. text: test case text the user wrote (also cases you wrote from requirements).",
202
+ "azure_devops: Test Case work items or a Test Plan. github: GitHub issues (by number, labels, or an issue search). jira: issues. xray: Xray Test issues or a Test Plan. testrail: cases, a run, a plan, or a suite. zephyr: Zephyr Scale test cases, a test cycle, or a folder. qtest: qTest test cases, a test cycle, or a suite. file: .txt/.md/.csv/.yaml/.json/.feature files. text: test case text the user wrote (also cases you wrote from requirements).",
196
203
  },
197
204
  ids: {
198
205
  type: "array",
199
206
  items: { type: "string" },
200
- description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr), or test case ids (qtest)",
207
+ description: "Work item ids (azure_devops), issue keys (jira, xray), case ids like C12 (testrail), keys like SHOP-T3 (zephyr), test case ids (qtest), or issue numbers (github: 12, or owner/repo#12)",
201
208
  },
202
209
  plan: { type: "string", description: "Test plan id (azure_devops, testrail) or Test Plan issue key (xray)" },
203
210
  suite: { type: "string", description: "Suite id: inside the Azure DevOps plan (omit for every suite), a TestRail suite, or a qTest test suite" },
@@ -205,6 +212,9 @@ const SOURCES_SCHEMA = {
205
212
  cycle: { type: "string", description: "zephyr: test cycle key like SHOP-R5. qtest: test cycle id" },
206
213
  folder: { type: "string", description: "zephyr: folder id" },
207
214
  jql: { type: "string", description: "JQL query (jira, xray)" },
215
+ repo: { type: "string", description: "github: owner/repo (default: sources.github.repo); ids may also be owner/repo#12" },
216
+ labels: { type: "array", items: { type: "string" }, description: "github: open issues with all these labels" },
217
+ query: { type: "string", description: 'github: a GitHub issue search, e.g. "login in:title is:open label:test-case"' },
208
218
  paths: { type: "array", items: { type: "string" }, description: "Files, relative to the working directory" },
209
219
  text: { type: "string", description: "The test case itself: title on the first line, then steps and expected results" },
210
220
  },
@@ -253,6 +263,61 @@ function summarizeRun(result: RunResult, runDir: string, files: string[]) {
253
263
  }
254
264
  }
255
265
 
266
+ const DEVICES_SCHEMA = {
267
+ type: "array",
268
+ items: { type: "string" },
269
+ description:
270
+ "Names of saved devices (browser_devices) to run on, when the user asked for specific devices or browsers: each runs in parallel on its own remote browser and is reported separately. A device's name also picks its provider.",
271
+ }
272
+
273
+ /** One goal on several devices at once: a one-off case through the suite, so each device runs and reports on its own. */
274
+ async function runGoalOnDevices(goal: string, url: string | undefined, devices: string[], args: Record<string, unknown>, ctx: ToolContext) {
275
+ const tc: TestCase = { id: "goal", title: clip(goal.split("\n")[0], 80) || "Goal", source: "text", description: goal, steps: [], ...(url ? { startUrl: url } : {}) }
276
+ const shown = deviceCases([tc], devices)
277
+ const parallel = suiteParallel(undefined, ctx.config.suite.parallel, devices.length, shown.length)
278
+ const emit = ctx.emit || (() => {})
279
+ emit({ type: "suite-start", cases: shown, parallel })
280
+ const suite = await runSuite({
281
+ config: ctx.config,
282
+ cases: [tc],
283
+ devices,
284
+ parallel,
285
+ writeBack: false,
286
+ headed: ctx.headed,
287
+ url,
288
+ maxSteps: ctx.maxSteps,
289
+ replay: ctx.replay,
290
+ memory: ctx.memory,
291
+ skills: skillNames(args.skills),
292
+ skillsRepo: ctx.cwd,
293
+ signal: ctx.signal,
294
+ execute: ctx.execute,
295
+ onEvent: (event) => emit({ type: "suite-event", event }),
296
+ })
297
+ emit({ type: "suite-done", suite })
298
+ const c = counts(suite.results)
299
+ return {
300
+ devices: suite.results.map((r) => ({ device: r.device, status: r.status, summary: clip(r.summary, 600), run_dir: r.runDir, final_url: r.finalUrl })),
301
+ passed: c.passed,
302
+ failed: c.failed + c.errors,
303
+ report: suite.files.html,
304
+ note: "Each device ran the goal on its own remote browser. To turn it into a test, run write_test with one device's run_dir.",
305
+ }
306
+ }
307
+
308
+ /** Devices a tool call asked for, else the session's pinned ones; names checked against the configuration. */
309
+ function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
310
+ const asked = Array.isArray(args.devices) ? args.devices.map((d) => String(d).trim()).filter(Boolean) : []
311
+ const devices = asked.length ? asked : ctx.devices || []
312
+ if (!devices.length) return []
313
+ const known = ctx.config.browser?.devices || {}
314
+ const missing = devices.filter((d) => !known[d])
315
+ if (missing.length) {
316
+ throw new Error(`No device named ${missing.join(", ")}. ${Object.keys(known).length ? `Saved devices: ${Object.keys(known).join(", ")}.` : "No device is saved yet."} Save it with browser_devices first.`)
317
+ }
318
+ return [...new Set(devices)]
319
+ }
320
+
256
321
  export const TOOLS: McpTool[] = [
257
322
  {
258
323
  name: "browser_run",
@@ -273,6 +338,7 @@ export const TOOLS: McpTool[] = [
273
338
  description:
274
339
  "Override the browser provider for this run when the user asked for one specifically. local uses the machine's own browser; browserstack and testmu use their configured YAML or browser.remote settings.",
275
340
  },
341
+ devices: DEVICES_SCHEMA,
276
342
  resume_from: {
277
343
  type: "string",
278
344
  description:
@@ -295,6 +361,10 @@ export const TOOLS: McpTool[] = [
295
361
  const resumeFrom = from ? (existsSync(path.resolve(ctx.cwd, from)) ? path.resolve(ctx.cwd, from) : from) : undefined
296
362
  const url = args.start_url ? String(args.start_url) : ctx.url
297
363
  const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
364
+ // Pinned devices are for testing the app: a task (reading a work item, looking something up) runs once, here.
365
+ const devices = args.purpose === "task" && !Array.isArray(args.devices) ? [] : devicesFor(args, ctx)
366
+ // Several devices: the goal runs as a one-off case on each, in parallel, through the suite (no write-back).
367
+ if (devices.length > 1) return runGoalOnDevices(goal, url, devices, args, ctx)
298
368
  const runDir = runDirectory(ctx.config.export.outDir)
299
369
  const emit = ctx.emit || (() => {})
300
370
  const t0 = Date.now()
@@ -303,7 +373,8 @@ export const TOOLS: McpTool[] = [
303
373
  const { result, files } = await (ctx.execute || executeRun)({
304
374
  config: ctx.config,
305
375
  goal,
306
- browserProvider: selectedBrowser,
376
+ browserProvider: devices.length ? undefined : selectedBrowser,
377
+ browserDevice: devices[0],
307
378
  url,
308
379
  headed: ctx.headed,
309
380
  maxSteps: ctx.maxSteps,
@@ -432,6 +503,7 @@ export const TOOLS: McpTool[] = [
432
503
  description:
433
504
  "Override the browser provider for every case in this suite when the user asked for one specifically.",
434
505
  },
506
+ devices: DEVICES_SCHEMA,
435
507
  skills: SKILLS_SCHEMA,
436
508
  },
437
509
  required: ["sources"],
@@ -443,10 +515,12 @@ export const TOOLS: McpTool[] = [
443
515
  const cases = all.filter((c) => !skip.has(c.id))
444
516
  if (!cases.length) return { ran: false, reason: "No case can run as written (suite.case_check: block).", check: checkResult(checks) }
445
517
  if (!cases.length) throw new Error("No test cases found.")
446
- const parallel = Math.min(Number(args.parallel) || ctx.config.suite.parallel, cases.length)
447
- const selectedBrowser = args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
518
+ const devices = devicesFor(args, ctx)
519
+ const shown = deviceCases(cases, devices)
520
+ const parallel = suiteParallel(Number(args.parallel) || undefined, ctx.config.suite.parallel, devices.length, shown.length)
521
+ const selectedBrowser = devices.length ? undefined : args.browser_provider ? browserProvider(args.browser_provider) : ctx.browserProvider
448
522
  const emit = ctx.emit || (() => {})
449
- emit({ type: "suite-start", cases, parallel })
523
+ emit({ type: "suite-start", cases: shown, parallel })
450
524
  // In a session that writes tests, each passed case goes to the coding agent right after its own browser run,
451
525
  // inside the suite, rather than every case being explored first and handed over at the end.
452
526
  const coder: typeof runCodeAgent = async (o) => {
@@ -472,6 +546,7 @@ export const TOOLS: McpTool[] = [
472
546
  cases,
473
547
  ...(ctx.writeTests ? { repo: ctx.cwd, coder } : {}),
474
548
  browserProvider: selectedBrowser,
549
+ devices,
475
550
  parallel,
476
551
  writeBack: typeof args.write_back === "boolean" ? args.write_back : undefined,
477
552
  headed: ctx.headed,
@@ -498,8 +573,10 @@ export const TOOLS: McpTool[] = [
498
573
  stopped: suite.stopped,
499
574
  report: suite.files.html,
500
575
  junit: suite.files.junit,
576
+ ...(devices.length > 1 ? { devices, posted_back: "one combined result per case: it fails when any device fails" } : {}),
501
577
  results: suite.results.map((r) => ({
502
578
  id: r.case.id,
579
+ ...(r.device ? { device: r.device } : {}),
503
580
  title: r.case.title,
504
581
  status: r.status,
505
582
  summary: clip(r.summary, 600),
@@ -762,6 +839,7 @@ export const TOOLS: McpTool[] = [
762
839
  },
763
840
  ...COMMAND_TOOLS,
764
841
  ...REPO_TEST_TOOLS,
842
+ ...DEFECT_TOOLS,
765
843
  ...PR_TOOLS,
766
844
  ...CASE_TOOLS,
767
845
  ...API_TOOLS,
@@ -770,6 +848,8 @@ export const TOOLS: McpTool[] = [
770
848
  ...CAPABILITY_TOOLS,
771
849
  ...NOTIFY_TOOLS,
772
850
  ...REPORT_TOOLS,
851
+ ...DEVICE_TOOLS,
852
+ ...ATTACH_TOOLS,
773
853
  ]
774
854
 
775
855
  /** The setup the agent should know about: where it works, which systems are configured, and the defaults. */
@@ -782,7 +862,7 @@ function knowledgeNotes(cfg: AppConfig): string {
782
862
  : `No product knowledge yet: the team can describe the product, roles, rules, and test data in ${cfg.knowledge.dir} (forgeqe init adds templates).`
783
863
  }
784
864
 
785
- export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider">): string {
865
+ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url" | "browserProvider" | "devices">): string {
786
866
  const { config: cfg } = ctx
787
867
  const s = cfg.sources
788
868
  const systems = [
@@ -823,6 +903,7 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
823
903
  const browserSkills = skillsFor(loadSkills(cfg.skills, ctx.cwd), "browser")
824
904
  const browserProvider = browser.provider || "local"
825
905
  const remoteProviders = (["browserstack", "testmu"] as const).filter((name) => browser.remotes?.[name])
906
+ const devices = Object.values(browser.devices || {}).map((d) => `${d.name} (${d.provider}: ${d.remote.browser} ${d.remote.browserVersion} on ${d.remote.platform || [d.remote.os, d.remote.osVersion].filter(Boolean).join(" ")})`)
826
907
  const skills = browserSkills.length
827
908
  ? [`Browser skills (pass the ones a goal needs as skills to browser_run or run_test_cases):`, ...catalogLines(browserSkills)].join("\n")
828
909
  : ""
@@ -853,6 +934,10 @@ export function environmentNotes(ctx: Pick<ToolContext, "config" | "cwd" | "url"
853
934
  keys.length ? `Set in ForgeQE's environment (names only, usable with set_ci_secret): ${keys.join(", ")}.` : "",
854
935
  ctx.url ? `The user pinned this start URL for browser runs: ${ctx.url}` : "",
855
936
  ctx.browserProvider ? `The user pinned this browser provider for runs in this session: ${ctx.browserProvider}.` : "",
937
+ devices.length
938
+ ? `Saved devices (pass names as devices to browser_run or run_test_cases; browser_devices adds, changes, or removes them): ${devices.join("; ")}.`
939
+ : "No devices are saved yet: when the user names devices or browsers to test on, save them with browser_devices.",
940
+ ctx.devices?.length ? `The user pinned these devices for runs in this session: ${ctx.devices.join(", ")}.` : "",
856
941
  skills,
857
942
  domainNotes(cfg),
858
943
  knowledgeNotes(cfg),
@@ -234,7 +234,7 @@ export function formatRound(r: RoundResult, nextAt?: Date, cwd = process.cwd()):
234
234
  if (ok.length) lines.push(` ↑ Alert sent: ${ok.join(", ")}`)
235
235
  for (const s of r.sent.filter((s) => !s.ok)) lines.push(` ✗ ${CHANNEL[s.channel]} alert not sent: ${s.error}`)
236
236
  if (r.suite) {
237
- const total = suiteUsage(r.suite.results)
237
+ const total = suiteUsage(r.suite.results, r.suite.analysis)
238
238
  if (total.usage.input || total.usage.output) lines.push(` Usage ${usageLine(total.usage, total.cost)}`)
239
239
  lines.push(` Report ${path.relative(cwd, r.suite.files.html) || r.suite.files.html}`)
240
240
  }
@@ -3,6 +3,9 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
3
3
  import path from "node:path"
4
4
 
5
5
  import type { CaseResult, CaseStatus, Cost, WriteBack } from "../cases/types.ts"
6
+ import type { DefectCluster } from "../cases/defects.ts"
7
+ import { DEFECT_HISTORY } from "../cases/defectHistory.ts"
8
+ import { defectTrends, type DefectTrends } from "../cases/predict.ts"
6
9
  import type { ReportEnv } from "./html.ts"
7
10
  import { addCost } from "../usage.ts"
8
11
  import { FAILURE_ACTION, FAILURE_LABEL } from "../cases/failure.ts"
@@ -106,6 +109,10 @@ export type ReportData = {
106
109
  cases: ReportCase[]
107
110
  posted: WriteBack[]
108
111
  history: HistoryRun[]
112
+ /** Failures grouped by cause, from the suite; unset for a single run and for reports written before defects. */
113
+ defects?: DefectCluster[]
114
+ /** How the defects of the suites in this folder went over time, when they have a history. */
115
+ defectTrends?: DefectTrends
109
116
  }
110
117
 
111
118
  export type ReportCase = {
@@ -183,6 +190,7 @@ export type DataInput = {
183
190
  results: CaseResult[]
184
191
  posted: WriteBack[]
185
192
  env?: ReportEnv
193
+ defects?: DefectCluster[]
186
194
  }
187
195
 
188
196
  export type DataOptions = {
@@ -243,6 +251,12 @@ export function buildReportData(p: DataInput, opts: DataOptions = {}): ReportDat
243
251
  cases,
244
252
  posted: p.posted.filter((w) => !w.caseId),
245
253
  history: [],
254
+ ...(p.defects ? { defects: p.defects } : {}),
255
+ }
256
+ if (p.kind === "suite" && existsSync(path.join(path.dirname(p.dir), DEFECT_HISTORY))) {
257
+ try {
258
+ data.defectTrends = defectTrends(path.dirname(p.dir))
259
+ } catch {}
246
260
  }
247
261
  if (p.kind === "suite") {
248
262
  data.history = [
@@ -4,7 +4,8 @@ import path from "node:path"
4
4
 
5
5
  import type { RunResult } from "../types.ts"
6
6
  import { costOf, type CaseResult, type WriteBack } from "../cases/types.ts"
7
- import type { AppConfig } from "../config.ts"
7
+ import type { AppConfig, BrowserConfig } from "../config.ts"
8
+ import type { DefectCluster } from "../cases/defects.ts"
8
9
  import { packageVersion } from "../tui/theme.ts"
9
10
  import { buildReportData, type DataOptions } from "./data.ts"
10
11
 
@@ -38,11 +39,21 @@ export function ciInfo(env = process.env): Pick<ReportEnv, "ci" | "commit" | "br
38
39
  return {}
39
40
  }
40
41
 
41
- export function reportEnv(config: AppConfig, opts: { url?: string; headed?: boolean } = {}): ReportEnv {
42
+ export function reportEnv(
43
+ config: AppConfig,
44
+ opts: { url?: string; headed?: boolean; browser?: Pick<BrowserConfig, "provider" | "remote"> & { device?: string } } = {},
45
+ ): ReportEnv {
42
46
  const llm = config.llm
47
+ const remote = opts.browser?.remote
48
+ const where = remote
49
+ ? `${remote.browser} ${remote.browserVersion} on ${remote.platform || [remote.os, remote.osVersion].filter(Boolean).join(" ")} (${opts.browser!.provider}${opts.browser!.device ? `, device ${opts.browser!.device}` : ""})`
50
+ : undefined
43
51
  return {
44
52
  model: `${config.activeProfile} (${llm.provider}/${llm.deployment || llm.model})`,
45
- browser: llm.provider === "mock" ? "synthetic page (mock profile)" : `Chrome, ${opts.headed || !config.browser.headless ? "visible" : "headless"}, ${config.browser.width}×${config.browser.height}`,
53
+ browser:
54
+ llm.provider === "mock"
55
+ ? "synthetic page (mock profile)"
56
+ : where || `Chrome, ${opts.headed || !config.browser.headless ? "visible" : "headless"}, ${config.browser.width}×${config.browser.height}`,
46
57
  startUrl: opts.url || config.browser.startUrl,
47
58
  version: packageVersion(),
48
59
  machine: `${os.hostname()} · ${process.platform}`,
@@ -68,6 +79,7 @@ export type ReportInput = {
68
79
  env?: ReportEnv
69
80
  /** "run" opens on the case itself; "suite" (the default) on the overview. */
70
81
  kind?: "suite" | "run"
82
+ defects?: DefectCluster[]
71
83
  }
72
84
 
73
85
  /** Built from report-ui/ by `bun run build:report`: one HTML file with the whole React app inlined. */