@capagents/forgeqe 0.0.0-stage → 1.0.1

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 (220) hide show
  1. package/HOW_TO_USE.md +1639 -0
  2. package/README.md +338 -2
  3. package/bin/forgeqe.d.ts +3 -0
  4. package/bin/forgeqe.js +188 -0
  5. package/docs/ARCHITECTURE.md +408 -0
  6. package/docs/security-data-flow.html +1870 -0
  7. package/docs/user-guide.html +2094 -0
  8. package/evals/domain/access-role-change.yaml +37 -0
  9. package/evals/domain/banking-payment-transfer.yaml +41 -0
  10. package/evals/domain/gdpr-account-erasure.yaml +30 -0
  11. package/evals/domain/insurance-claim-fnol.yaml +30 -0
  12. package/evals/domain/insurance-policy-renewal.yaml +37 -0
  13. package/evals/domain/lending-loan-application.yaml +36 -0
  14. package/forgeqe-architecture.html +718 -0
  15. package/forgeqe.example.yaml +203 -0
  16. package/knowledge/README.md +18 -0
  17. package/knowledge/business-rules.md +10 -0
  18. package/knowledge/compliance.md +16 -0
  19. package/knowledge/environments.md +5 -0
  20. package/knowledge/glossary.md +5 -0
  21. package/knowledge/product.md +16 -0
  22. package/knowledge/roles.md +9 -0
  23. package/knowledge/test-data.md +7 -0
  24. package/llms.example.json +51 -0
  25. package/package.json +63 -4
  26. package/prompts/README.md +34 -0
  27. package/prompts/api-scenarios.md +21 -0
  28. package/prompts/api-tests.md +39 -0
  29. package/prompts/brain.md +166 -0
  30. package/prompts/browser-capture.md +7 -0
  31. package/prompts/browser-case-report.md +7 -0
  32. package/prompts/browser-checks.md +14 -0
  33. package/prompts/browser-efficiency.md +7 -0
  34. package/prompts/browser-error-recovery.md +15 -0
  35. package/prompts/browser-extra.md +5 -0
  36. package/prompts/browser-goal-verification.md +13 -0
  37. package/prompts/browser-skill-select.md +6 -0
  38. package/prompts/domain-rule.md +13 -0
  39. package/prompts/eval-judge.md +19 -0
  40. package/prompts/explore-charters.md +27 -0
  41. package/prompts/find-tests.md +28 -0
  42. package/prompts/heal.md +40 -0
  43. package/prompts/pr-review.md +29 -0
  44. package/prompts/skill-rule.md +6 -0
  45. package/prompts/test-agent.md +34 -0
  46. package/prompts/test-reviewer.md +30 -0
  47. package/prompts/test-rules.md +28 -0
  48. package/py/forgeqe_engine/__init__.py +3 -0
  49. package/py/forgeqe_engine/__main__.py +3 -0
  50. package/py/forgeqe_engine/brand.py +453 -0
  51. package/py/forgeqe_engine/brand_assets.py +6 -0
  52. package/py/forgeqe_engine/browser.py +196 -0
  53. package/py/forgeqe_engine/capture.py +538 -0
  54. package/py/forgeqe_engine/checks.py +98 -0
  55. package/py/forgeqe_engine/cli.py +188 -0
  56. package/py/forgeqe_engine/diagnostics.py +609 -0
  57. package/py/forgeqe_engine/llm.py +195 -0
  58. package/py/forgeqe_engine/logins.py +251 -0
  59. package/py/forgeqe_engine/loops.py +224 -0
  60. package/py/forgeqe_engine/memory.py +1004 -0
  61. package/py/forgeqe_engine/network.py +244 -0
  62. package/py/forgeqe_engine/prompts.py +20 -0
  63. package/py/forgeqe_engine/replay.py +240 -0
  64. package/py/forgeqe_engine/report.py +173 -0
  65. package/py/forgeqe_engine/service.py +1335 -0
  66. package/py/forgeqe_engine/settings.py +93 -0
  67. package/py/forgeqe_engine/skills.py +88 -0
  68. package/py/forgeqe_engine/summary.py +239 -0
  69. package/py/forgeqe_engine/urls.py +81 -0
  70. package/py/forgeqe_engine/workflow_yaml.py +689 -0
  71. package/skills/dotnet-nunit-playwright/SKILL.md +34 -0
  72. package/skills/dynamics-365/SKILL.md +21 -0
  73. package/skills/playwright-typescript/SKILL.md +35 -0
  74. package/skills/writing-skills/SKILL.md +30 -0
  75. package/src/api/plan.ts +246 -0
  76. package/src/api/run.ts +204 -0
  77. package/src/api/scenario.ts +502 -0
  78. package/src/api/schema.ts +170 -0
  79. package/src/api/spec.ts +624 -0
  80. package/src/api/store.ts +232 -0
  81. package/src/api/traffic.ts +281 -0
  82. package/src/api/types.ts +163 -0
  83. package/src/api/write.ts +272 -0
  84. package/src/auth/signin.ts +494 -0
  85. package/src/auth/store.ts +170 -0
  86. package/src/brain/index.ts +189 -0
  87. package/src/brain/prompt.ts +19 -0
  88. package/src/capabilities/docs.ts +101 -0
  89. package/src/capabilities/guide.ts +455 -0
  90. package/src/capabilities/registry.ts +343 -0
  91. package/src/capabilities/render.ts +106 -0
  92. package/src/capabilities/status.ts +137 -0
  93. package/src/card.ts +11 -0
  94. package/src/cases/ado.ts +325 -0
  95. package/src/cases/api.ts +117 -0
  96. package/src/cases/bugs.ts +223 -0
  97. package/src/cases/confluence.ts +111 -0
  98. package/src/cases/context.ts +223 -0
  99. package/src/cases/dashboards.ts +137 -0
  100. package/src/cases/failure.ts +97 -0
  101. package/src/cases/files.ts +295 -0
  102. package/src/cases/goal.ts +53 -0
  103. package/src/cases/http.ts +111 -0
  104. package/src/cases/index.ts +131 -0
  105. package/src/cases/jira.ts +122 -0
  106. package/src/cases/qtest.ts +108 -0
  107. package/src/cases/quality.ts +190 -0
  108. package/src/cases/rag.ts +100 -0
  109. package/src/cases/readiness.ts +284 -0
  110. package/src/cases/save.ts +149 -0
  111. package/src/cases/sprints.ts +148 -0
  112. package/src/cases/testrail.ts +176 -0
  113. package/src/cases/types.ts +107 -0
  114. package/src/cases/wiki.ts +105 -0
  115. package/src/cases/xray.ts +206 -0
  116. package/src/cases/zephyr.ts +153 -0
  117. package/src/childEnv.ts +86 -0
  118. package/src/cli/args.ts +114 -0
  119. package/src/cli/help.ts +167 -0
  120. package/src/cli/index.ts +1142 -0
  121. package/src/cli/terminal.ts +187 -0
  122. package/src/code/areas.ts +337 -0
  123. package/src/code/attention.ts +214 -0
  124. package/src/code/find.ts +540 -0
  125. package/src/code/handoff.ts +541 -0
  126. package/src/code/history.ts +170 -0
  127. package/src/code/impact.ts +231 -0
  128. package/src/code/index.ts +34 -0
  129. package/src/code/livebrowser.ts +67 -0
  130. package/src/code/loop.ts +603 -0
  131. package/src/code/notes.ts +75 -0
  132. package/src/code/profile.ts +226 -0
  133. package/src/code/prompt.ts +303 -0
  134. package/src/code/prreview.ts +649 -0
  135. package/src/code/prtests.ts +103 -0
  136. package/src/code/replay.ts +772 -0
  137. package/src/code/review.ts +202 -0
  138. package/src/code/runtime.ts +781 -0
  139. package/src/code/slices.ts +159 -0
  140. package/src/code/testrun.ts +187 -0
  141. package/src/code/traceability.ts +100 -0
  142. package/src/code/worktree.ts +153 -0
  143. package/src/commands.ts +101 -0
  144. package/src/config.ts +970 -0
  145. package/src/detail.ts +65 -0
  146. package/src/domain.ts +19 -0
  147. package/src/engine/bridge.ts +359 -0
  148. package/src/engine/mock.ts +102 -0
  149. package/src/engine/setup.ts +298 -0
  150. package/src/entra.ts +154 -0
  151. package/src/env.ts +24 -0
  152. package/src/evals/domain.ts +338 -0
  153. package/src/explore/charters.ts +151 -0
  154. package/src/explore/run.ts +280 -0
  155. package/src/explore/story.ts +106 -0
  156. package/src/guard.ts +51 -0
  157. package/src/heal/loop.ts +357 -0
  158. package/src/heal/prompt.ts +143 -0
  159. package/src/heal/report.ts +95 -0
  160. package/src/index.ts +12 -0
  161. package/src/init.ts +102 -0
  162. package/src/knowledge.ts +114 -0
  163. package/src/legacyEnv.ts +9 -0
  164. package/src/llm.ts +61 -0
  165. package/src/locale.ts +25 -0
  166. package/src/logins.ts +148 -0
  167. package/src/managed.ts +101 -0
  168. package/src/mcp/apitools.ts +335 -0
  169. package/src/mcp/captools.ts +25 -0
  170. package/src/mcp/casetools.ts +223 -0
  171. package/src/mcp/commands.ts +421 -0
  172. package/src/mcp/exploretools.ts +129 -0
  173. package/src/mcp/external.ts +172 -0
  174. package/src/mcp/git.ts +78 -0
  175. package/src/mcp/notifytools.ts +49 -0
  176. package/src/mcp/prtools.ts +261 -0
  177. package/src/mcp/qualitytools.ts +154 -0
  178. package/src/mcp/repotests.ts +286 -0
  179. package/src/mcp/server.ts +234 -0
  180. package/src/mcp/tools.ts +810 -0
  181. package/src/memory.ts +250 -0
  182. package/src/money.ts +39 -0
  183. package/src/monitor/alerts.ts +94 -0
  184. package/src/monitor/background.ts +68 -0
  185. package/src/monitor/run.ts +263 -0
  186. package/src/monitor/state.ts +105 -0
  187. package/src/notify/events.ts +144 -0
  188. package/src/notify/notice.ts +177 -0
  189. package/src/prices.ts +175 -0
  190. package/src/print.ts +54 -0
  191. package/src/privacy.ts +35 -0
  192. package/src/prompts.ts +165 -0
  193. package/src/quality/budgets.ts +249 -0
  194. package/src/quality/history.ts +149 -0
  195. package/src/quality/visual.ts +201 -0
  196. package/src/report/data.ts +475 -0
  197. package/src/report/html.ts +106 -0
  198. package/src/report/template.html +84 -0
  199. package/src/run.ts +243 -0
  200. package/src/sessionLog.ts +216 -0
  201. package/src/sessionLogins.ts +94 -0
  202. package/src/skills/registry.ts +217 -0
  203. package/src/skills/scaffold.ts +65 -0
  204. package/src/skills/select.ts +37 -0
  205. package/src/skills/view.ts +44 -0
  206. package/src/suite/report.ts +135 -0
  207. package/src/suite/run.ts +303 -0
  208. package/src/summary.ts +188 -0
  209. package/src/tools.ts +130 -0
  210. package/src/tui/App.tsx +983 -0
  211. package/src/tui/clipboard.ts +36 -0
  212. package/src/tui/components.tsx +263 -0
  213. package/src/tui/dialogs.ts +65 -0
  214. package/src/tui/main.tsx +20 -0
  215. package/src/tui/text.ts +28 -0
  216. package/src/tui/theme.ts +104 -0
  217. package/src/tui/transcript.tsx +344 -0
  218. package/src/types.ts +146 -0
  219. package/src/update.ts +311 -0
  220. package/src/usage.ts +158 -0
@@ -0,0 +1,272 @@
1
+ import { existsSync, realpathSync, statSync } from "node:fs"
2
+ import path from "node:path"
3
+
4
+ import type { AppConfig } from "../config.ts"
5
+ import { privateEnvNames } from "../childEnv.ts"
6
+ import { addUsage } from "../usage.ts"
7
+ import type { CodeEvent, Usage } from "../types.ts"
8
+ import { loadSkills } from "../skills/registry.ts"
9
+ import { buildProfile, profileSection } from "../code/profile.ts"
10
+ import { RESULT_SCHEMA, type AgentReport } from "../code/prompt.ts"
11
+ import { openSession, type AgentSpec, type OpencodeSession } from "../code/runtime.ts"
12
+ import { headAndTail, runTestCommand } from "../code/testrun.ts"
13
+ import { ownPaths, protectPatterns } from "../code/worktree.ts"
14
+ import { associateAdoAutomation, recordMapping } from "../code/traceability.ts"
15
+ import type { WriteBack } from "../cases/types.ts"
16
+ import { briefMarkdown, scenarioMarkdown } from "./store.ts"
17
+ import type { ContractSet, Scenario } from "./types.ts"
18
+ import { prompt } from "../prompts.ts"
19
+ import { domainNotes } from "../domain.ts"
20
+
21
+ /**
22
+ * The coding agent writes API and contract tests for a contract set in the repository's own framework, and ForgeQE
23
+ * runs them until they pass: Playwright's request fixture in a Playwright repository, pytest with requests or httpx,
24
+ * REST Assured, RestSharp or HttpClient, supertest... whatever the repository already uses for HTTP, else what its
25
+ * language uses most. No browser: these tests call the API directly, so they are fast and do not break when the page
26
+ * changes.
27
+ */
28
+
29
+ export const apiAgentPrompt = () => prompt("api-tests")
30
+
31
+ export const API_AGENT: AgentSpec = {
32
+ name: "forgeqe-api",
33
+ description: "Writes API and contract tests in this repository from ForgeQE's contracts",
34
+ get prompt() {
35
+ return apiAgentPrompt()
36
+ },
37
+ permission: { "live_*": "deny" },
38
+ skills: "scribe",
39
+ }
40
+
41
+ /** The test result Scribe reports, plus the scenario tests' names and the regression command for changed helpers. */
42
+ export const API_RESULT_SCHEMA = {
43
+ ...RESULT_SCHEMA,
44
+ properties: {
45
+ ...RESULT_SCHEMA.properties,
46
+ scenario_tests: {
47
+ type: "array",
48
+ items: { type: "object", properties: { scenario_id: { type: "string" }, test_name: { type: "string" } }, required: ["scenario_id", "test_name"] },
49
+ description: "Each scenario's test, by the scenario id ForgeQE gave and the test's full name as the framework reports it",
50
+ },
51
+ helpers_changed: { type: "array", items: { type: "string" }, description: "Existing helpers you changed that other tests use" },
52
+ regression_command: { type: "string", description: "When you changed helpers other tests use: the command that runs those tests" },
53
+ },
54
+ } as const
55
+
56
+ type ApiReport = AgentReport & { scenario_tests?: { scenario_id: string; test_name: string }[]; helpers_changed?: string[]; regression_command?: string }
57
+
58
+ export type ApiTestsResult = {
59
+ status: "passed" | "failed" | "blocked" | "stopped"
60
+ repo: string
61
+ attempts: number
62
+ testFile?: string
63
+ command?: string
64
+ changed: string[]
65
+ reused: string[]
66
+ notes: string
67
+ output: string
68
+ usage: Usage
69
+ model: string
70
+ /** Scenarios with a test case: the test each got, and the write-back to the test case's system. */
71
+ mapped: { caseId: string; testName: string }[]
72
+ writeBack: WriteBack[]
73
+ helpersChanged: string[]
74
+ }
75
+
76
+ /** The names of environment variables api.headers reads (never their values), for the agent to use the same ones. */
77
+ export function credentialNames(config: AppConfig): string[] {
78
+ const names = new Set<string>()
79
+ for (const v of Object.values(config.api?.headers || {})) for (const m of v.matchAll(/\$\{(\w+)\}|\$(\w+)/g)) names.add(m[1] || m[2])
80
+ return [...names]
81
+ }
82
+
83
+ export function apiTestsPrompt(set: ContractSet, opts: { baseUrl: string; profile?: string; credentials: string[]; headers: string[]; writes: boolean; scenarios?: Scenario[] }): string {
84
+ if (opts.scenarios?.length) return scenarioTestsPrompt(set, { ...opts, scenarios: opts.scenarios })
85
+ const runnable = set.contracts.filter((c) => opts.writes || !c.writes)
86
+ const leftOut = set.contracts.length - runnable.length
87
+ return [
88
+ `Write API and contract tests for these ${runnable.length} operation${runnable.length === 1 ? "" : "s"} of ${set.name}.`,
89
+ `Base URL: ${opts.baseUrl || "(not known: read it from API_BASE_URL)"}`,
90
+ opts.headers.length
91
+ ? `Every call sends these headers: ${opts.headers.join(", ")}. Read their values from the environment variables ${opts.credentials.join(", ") || "(none named)"}, as forgeqe.yaml's api.headers does.`
92
+ : set.contracts.some((c) => c.auth)
93
+ ? "Some operations need auth (see each one). No credential variables are configured: read a bearer token from API_TOKEN, and skip the test with a clear message when it is not set."
94
+ : "",
95
+ leftOut ? `${leftOut} operation${leftOut === 1 ? "" : "s"} that change data are left out: do not write tests for them.` : "",
96
+ opts.profile || "",
97
+ "",
98
+ "The contracts (contracts.json has the full schemas):",
99
+ "",
100
+ briefMarkdown({ ...set, contracts: runnable }),
101
+ ]
102
+ .filter((l, i, all) => l !== "" || all[i - 1] !== "")
103
+ .join("\n")
104
+ }
105
+
106
+ function credentialLine(set: ContractSet, opts: { credentials: string[]; headers: string[] }): string {
107
+ return opts.headers.length
108
+ ? `Every call sends these headers: ${opts.headers.join(", ")}. Read their values from the environment variables ${opts.credentials.join(", ") || "(none named)"}, as forgeqe.yaml's api.headers does.`
109
+ : set.contracts.some((c) => c.auth)
110
+ ? "Some operations need auth. No credential variables are configured: read a bearer token from API_TOKEN, and skip the test with a clear message when it is not set."
111
+ : ""
112
+ }
113
+
114
+ /** End-to-end scenarios to write as tests, on the repository's existing API helpers. */
115
+ export function scenarioTestsPrompt(set: ContractSet, opts: { baseUrl: string; profile?: string; credentials: string[]; headers: string[]; scenarios: Scenario[] }): string {
116
+ const used = new Set(opts.scenarios.flatMap((s) => [...s.steps, ...(s.cleanup || [])].map((st) => st.operation).filter(Boolean)))
117
+ const ops = set.contracts.filter((c) => used.has(c.id))
118
+ return [
119
+ `Write ${opts.scenarios.length === 1 ? "an end-to-end API scenario test" : `${opts.scenarios.length} end-to-end API scenario tests`} for ${set.name}: one test per scenario, built on the repository's existing API helpers (map them first, as your instructions say).`,
120
+ `Base URL: ${opts.baseUrl || "(not known: read it from API_BASE_URL)"}`,
121
+ credentialLine(set, opts),
122
+ opts.profile || "",
123
+ "",
124
+ briefMarkdown({ ...set, contracts: ops, scenarios: opts.scenarios, skipped: undefined }),
125
+ ]
126
+ .filter((l, i, all) => l !== "" || all[i - 1] !== "")
127
+ .join("\n")
128
+ }
129
+
130
+ export async function writeApiTests(opts: {
131
+ config: AppConfig
132
+ repo: string
133
+ set: ContractSet
134
+ setDir: string
135
+ baseUrl?: string
136
+ writes?: boolean
137
+ /** Write these scenarios as end-to-end tests instead of one test per contract. */
138
+ scenarios?: Scenario[]
139
+ /** Link scenario tests to their Azure DevOps test cases (Associated Automation). Default: sources.write_back. */
140
+ writeBack?: boolean
141
+ signal?: AbortSignal
142
+ onEvent?: (e: CodeEvent) => void
143
+ open?: (o: Parameters<typeof openSession>[0]) => Promise<OpencodeSession>
144
+ runTest?: typeof runTestCommand
145
+ }): Promise<ApiTestsResult> {
146
+ const given = path.resolve(opts.repo)
147
+ if (!existsSync(given) || !statSync(given).isDirectory()) throw new Error(`Repo not found: ${given}`)
148
+ const repo = realpathSync(given)
149
+ const { config } = opts
150
+ const emit = opts.onEvent || (() => {})
151
+ const runTest = opts.runTest || runTestCommand
152
+ const maxAttempts = Math.max(1, config.code.maxAttempts)
153
+ const baseUrl = opts.baseUrl || config.api?.baseUrl || opts.set.baseUrl
154
+ const result: ApiTestsResult = { status: "failed", repo, attempts: 0, changed: [], reused: [], notes: "", output: "", usage: { input: 0, output: 0 }, model: "", mapped: [], writeBack: [], helpersChanged: [] }
155
+ const own = ownPaths(repo, [config.export.outDir, config.memory.dir])
156
+ const session = await (opts.open || openSession)({
157
+ repo,
158
+ profile: config.llm,
159
+ domain: domainNotes(config),
160
+ model: config.code.model,
161
+ bin: config.code.opencode,
162
+ readDirs: [repo, opts.setDir],
163
+ hide: privateEnvNames(config),
164
+ signal: opts.signal,
165
+ onEvent: emit,
166
+ protect: protectPatterns(repo, own, []),
167
+ stallAfter: config.code.stallAfter ?? 5,
168
+ agent: { ...API_AGENT, steps: config.code.maxSteps ?? 50 },
169
+ skills: loadSkills(config.skills, repo).skills,
170
+ schema: API_RESULT_SCHEMA as unknown as Record<string, unknown>,
171
+ title: opts.scenarios?.length ? "ForgeQE API scenarios" : "ForgeQE API tests",
172
+ })
173
+ result.model = session.model
174
+ const budget = config.code.maxTokens
175
+ const credentials = credentialNames(config)
176
+ const env = { ...(baseUrl ? { API_BASE_URL: baseUrl } : {}) }
177
+ try {
178
+ const profile = buildProfile(repo)
179
+ let message = apiTestsPrompt(opts.set, { baseUrl, profile: profile ? profileSection(profile) : undefined, credentials, headers: Object.keys(config.api?.headers || {}), writes: Boolean(opts.writes), scenarios: opts.scenarios })
180
+ emit({ type: "code", kind: "info", text: opts.scenarios?.length ? `Scribe (${session.model}) is writing ${opts.scenarios.length} API scenario test${opts.scenarios.length === 1 ? "" : "s"} in ${repo}, on its existing API helpers` : `Scribe (${session.model}) is writing API tests for ${opts.set.contracts.length} operations in ${repo}` })
181
+ let report: ApiReport | null = null
182
+ for (let attempt = 1; attempt <= maxAttempts && !opts.signal?.aborted; attempt++) {
183
+ result.attempts = attempt
184
+ const reply = await session.prompt(message)
185
+ report = reply.report as ApiReport | null
186
+ if (!report) {
187
+ result.notes = reply.error ? `Scribe stopped: ${reply.error}` : "Scribe gave no result."
188
+ break
189
+ }
190
+ result.testFile = report.test_file
191
+ result.command = report.command
192
+ result.changed = report.changed || []
193
+ result.reused = report.reused || []
194
+ result.notes = report.notes || ""
195
+ if (report.status === "blocked") {
196
+ result.status = "blocked"
197
+ result.notes = report.blocked_reason || result.notes
198
+ break
199
+ }
200
+ if (!report.command) {
201
+ message = "Give the command that runs only these API tests from the repository root, in the structured result."
202
+ continue
203
+ }
204
+ emit({ type: "code", kind: "info", text: `ForgeQE runs the API tests (attempt ${attempt} of ${maxAttempts}): ${report.command}` })
205
+ const prefix = Object.entries(env).map(([k, v]) => `${k}=${JSON.stringify(v)} `).join("")
206
+ const run = await runTest({ command: process.platform === "win32" ? report.command : `${prefix}${report.command}`, cwd: repo, timeoutSeconds: config.code.testTimeoutSeconds, signal: opts.signal, save: path.join(opts.setDir, `tests-attempt-${attempt}`), hide: privateEnvNames(config) })
207
+ result.output = run.output
208
+ result.helpersChanged = report.helpers_changed || []
209
+ // Changed shared helpers: the tests that already use them must still pass.
210
+ const regression = run.ok && report.regression_command?.trim() && report.regression_command.trim() !== report.command.trim() ? report.regression_command.trim() : ""
211
+ if (regression) {
212
+ emit({ type: "code", kind: "info", text: `ForgeQE runs the tests that use the changed helpers: ${regression}` })
213
+ const again = await runTest({ command: process.platform === "win32" ? regression : `${prefix}${regression}`, cwd: repo, timeoutSeconds: config.code.testTimeoutSeconds, signal: opts.signal, save: path.join(opts.setDir, `regression-attempt-${attempt}`), hide: privateEnvNames(config) })
214
+ if (!again.ok) {
215
+ emit({ type: "code", kind: "info", text: `Tests that use the changed helpers fail now (exit ${again.exitCode ?? "timeout"})` })
216
+ result.output = again.output
217
+ message = [
218
+ `The new tests pass, but tests that already used the helpers you changed now fail. ForgeQE ran: ${regression}`,
219
+ "",
220
+ "```",
221
+ headAndTail(again.output),
222
+ "```",
223
+ "",
224
+ "Change the helpers so the existing tests pass as before (add, do not change what they rely on), then give the structured result again.",
225
+ ].join("\n")
226
+ continue
227
+ }
228
+ }
229
+ if (run.ok) {
230
+ result.status = "passed"
231
+ emit({ type: "code", kind: "info", text: `API tests passed in ${(run.ms / 1000).toFixed(1)}s` })
232
+ break
233
+ }
234
+ emit({ type: "code", kind: "info", text: `API tests failed (exit ${run.exitCode ?? "timeout"})` })
235
+ if (budget && session.usage.input + session.usage.output > budget) {
236
+ result.notes = `Stopped: the job reached its token budget (code.max_tokens ${budget}).`
237
+ break
238
+ }
239
+ message = [
240
+ `ForgeQE ran: ${report.command}`,
241
+ `It failed (exit ${run.exitCode ?? "timeout: the command did not finish"}). The output${run.log ? ` (full output in ${run.log})` : ""}:`,
242
+ "",
243
+ "```",
244
+ headAndTail(run.output),
245
+ "```",
246
+ "",
247
+ "Fix the tests (or say in notes where the API itself is wrong), then give the structured result again.",
248
+ ].join("\n")
249
+ }
250
+ if (opts.signal?.aborted) result.status = "stopped"
251
+ if (result.status === "passed" && report?.test_file) await mapScenarios(opts, repo, report, result)
252
+ } finally {
253
+ result.usage = addUsage(result.usage, session.usage)
254
+ await session.close()
255
+ }
256
+ return result
257
+ }
258
+
259
+ /** Scenario tests that automate a test case: recorded in the repository's mapping file, and in Azure DevOps when write-back is on. */
260
+ async function mapScenarios(opts: { config: AppConfig; scenarios?: Scenario[]; writeBack?: boolean }, repo: string, report: ApiReport, result: ApiTestsResult): Promise<void> {
261
+ for (const sc of opts.scenarios || []) {
262
+ if (!sc.caseId) continue
263
+ const testName = report.scenario_tests?.find((t) => t.scenario_id === sc.id)?.test_name || `${sc.caseId} ${sc.name}`
264
+ recordMapping(repo, { caseId: sc.caseId, title: sc.caseTitle || sc.name, source: sc.caseSource || "text", ...(sc.caseUrl ? { url: sc.caseUrl } : {}), status: "automated", testFile: report.test_file, testName, command: report.command })
265
+ result.mapped.push({ caseId: sc.caseId, testName })
266
+ if (sc.caseSource === "ado" && (opts.writeBack ?? opts.config.sources.writeBack)) {
267
+ result.writeBack.push(
268
+ await associateAdoAutomation(opts.config.sources, { id: sc.caseId, title: sc.caseTitle || sc.name, source: "ado", steps: [], ...(sc.caseUrl ? { url: sc.caseUrl } : {}), ...(sc.caseRef ? { ref: sc.caseRef } : {}) }, { name: testName, file: report.test_file!, command: report.command }),
269
+ )
270
+ }
271
+ }
272
+ }