@capagents/forgeqe 1.1.9 → 1.2.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.
@@ -11,6 +11,7 @@ import { loadSkills } from "../skills/registry.ts"
11
11
  import type { Price } from "../usage.ts"
12
12
  import { prompt } from "../prompts.ts"
13
13
  import { domainNotes } from "../domain.ts"
14
+ import type { TestTarget } from "./prompt.ts"
14
15
 
15
16
  const OUTPUT_CHARS = 4_000
16
17
  /** Changed files are shown in the prompt up to these sizes; the reviewer reads the rest itself. */
@@ -78,6 +79,8 @@ export type ReviewInput = {
78
79
  outlines?: string
79
80
  /** The repository's browser settings files and their values, as ForgeQE read them. */
80
81
  settings?: string[]
82
+ /** The existing test the job was asked to reuse or extend, from measured coverage or the user's choice. */
83
+ target?: TestTarget
81
84
  }
82
85
 
83
86
  const LIST_MAX = 60
@@ -125,6 +128,16 @@ export function reviewPrompt(input: ReviewInput, repo?: string): string {
125
128
  `The handoff it worked from: ${handoff.file}`,
126
129
  ...(checks ? ["", "The browser agent's checks, from the handoff:", checks] : []),
127
130
  "",
131
+ ...(input.target
132
+ ? [
133
+ "",
134
+ input.target.mode === "extend"
135
+ ? `The job was asked to extend the existing test ${input.target.test} (${input.target.testFile}) with only what it lacked: ${input.target.missing.join("; ")}. Adding those steps and checks to that test is the requested change, not a rewrite. A step or assertion of it removed or weakened, those steps duplicated in a new test file, or a shared page-object member changed in a way its other users would notice, is a correctness issue.`
136
+ : input.target.mode === "update"
137
+ ? `The user asked to update the existing test ${input.target.test} (${input.target.testFile}) to match this run. Changing its steps, locators, or expected values to match the run is the requested change. Removing an assertion the run still supports, or writing a new test file instead, is a correctness issue.`
138
+ : `The job was asked to reuse the existing test ${input.target.test} (${input.target.testFile}) as it is, since it already covers the flow. Any change to it, or a new test, is a correctness issue.`,
139
+ ]
140
+ : []),
128
141
  `Test file: ${report.test_file}`,
129
142
  `Command: ${report.command}`,
130
143
  input.changes
@@ -1,3 +1,7 @@
1
+ import { coverageLines, EXTEND_FROM, measureCoverage, type Candidate } from "../code/coverage.ts"
2
+ import { buildGraph } from "../code/graph/graph.ts"
3
+ import { EXISTING_TEST_FILE } from "../code/notes.ts"
4
+ import type { ExistingTest, TestTarget } from "../code/prompt.ts"
1
5
  import { existsSync, readFileSync } from "node:fs"
2
6
  import path from "node:path"
3
7
 
@@ -27,6 +31,62 @@ const clip = (s: string | undefined, n: number) => {
27
31
  }
28
32
 
29
33
  /** Marks a browser_run that was a task (a lookup in a tool), not a flow to automate; write_test refuses such runs. */
34
+
35
+ /**
36
+ * Reuse, extend, or new, from what the repository's tests already cover of the run (code graph and page memory).
37
+ * The clear cases go ahead and say why; where the evidence fits more than one way, the user picks, seeing each
38
+ * option's effect. A mode the user gave (through ForgeQE's agent) is followed without asking.
39
+ */
40
+ export async function chooseTarget(runDir: string, repo: string, args: Record<string, any>, ctx: ToolContext): Promise<{ target?: TestTarget } | { cancelled: true }> {
41
+ const asked = typeof args.mode === "string" && args.mode !== "auto" ? (args.mode as "new" | "extend" | "reuse" | "update") : null
42
+ if (asked === "new") return {}
43
+ const graph = await buildGraph(repo).catch(() => null)
44
+ if (!graph) return {}
45
+ const cov = measureCoverage(runDir, graph, ctx.config.memory.dir)
46
+ const named = args.test_file ? cov.candidates.find((c) => c.file === String(args.test_file).replace(/^\.\//, "")) : undefined
47
+ let decision = cov.decision
48
+ if (asked) decision = { mode: asked, ask: false, target: named || cov.candidates[0], why: "the user asked for it" }
49
+ const lines = coverageLines(cov)
50
+ const toTarget = (mode: TestTarget["mode"], c: Candidate): TestTarget => ({
51
+ mode,
52
+ testFile: c.file,
53
+ test: c.test,
54
+ line: c.line,
55
+ covered: c.covered.map((i) => cov.items[i]!.label),
56
+ missing: c.missing.map((i) => cov.items[i]!.label),
57
+ why: `it covers ${c.covered.length} of ${c.covered.length + c.missing.length} steps and checks this run located`,
58
+ })
59
+ const say = (title: string, why: string) =>
60
+ ctx.emit?.({ type: "card", card: { status: "info", title, stats: why, body: lines.length ? ["What the repository's tests already cover of this run:", ...lines] : [] } })
61
+ if (decision.ask && ctx.ask) {
62
+ const options = cov.candidates.filter((c) => c.share >= EXTEND_FROM).slice(0, 2).flatMap((c, n) => {
63
+ const name = `${path.basename(c.file)}`
64
+ return c.missing.length
65
+ ? [{ id: `extend:${n}`, label: `Extend ${name} (adds ${c.missing.length})` }]
66
+ : [{ id: `reuse:${n}`, label: `Reuse ${name} as it is` }]
67
+ })
68
+ const answer = await ctx.ask({
69
+ title: "Existing tests already cover this flow. Which way should Scribe go?",
70
+ detail: `${decision.why}. Extending keeps the test's steps and adds only what is missing; a new test duplicates what they cover.`,
71
+ lines,
72
+ options: [...options, { id: "new", label: "Write a new test" }, { id: "cancel", label: "Cancel" }],
73
+ })
74
+ if (!answer || answer === "cancel") return { cancelled: true }
75
+ if (answer === "new") return {}
76
+ const [mode, n] = answer.split(":") as ["reuse" | "extend", string]
77
+ const c = cov.candidates.filter((x) => x.share >= EXTEND_FROM)[Number(n)]!
78
+ say(mode === "reuse" ? `Reusing ${c.test}` : `Extending ${c.test}`, "the user chose it")
79
+ return { target: toTarget(mode, c) }
80
+ }
81
+ if (decision.mode === "new" || !decision.target) {
82
+ if (cov.candidates.length) say("Writing a new test", decision.why)
83
+ return {}
84
+ }
85
+ const mode: TestTarget["mode"] = decision.mode === "reuse" ? "reuse" : decision.mode === "update" ? "update" : "extend"
86
+ say(mode === "reuse" ? `Reusing ${decision.target.test}` : mode === "update" ? `Updating ${decision.target.test}` : `Extending ${decision.target.test}`, decision.why)
87
+ return { target: toTarget(mode, decision.target) }
88
+ }
89
+
30
90
  export const TASK_FILE = "task.json"
31
91
 
32
92
  const inCwd = (ctx: ToolContext, p: unknown) => (p ? path.resolve(ctx.cwd, String(p)) : ctx.cwd)
@@ -96,6 +156,13 @@ export const COMMAND_TOOLS: McpTool[] = [
96
156
  properties: {
97
157
  run_dir: { type: "string", description: "Run folder from browser_run or run_test_cases (default: the latest run)" },
98
158
  repo: { type: "string", description: "Repository to write the test in, relative to the working directory (default: the working directory)" },
159
+ mode: {
160
+ type: "string",
161
+ enum: ["auto", "new", "extend", "reuse", "update"],
162
+ description:
163
+ "auto (default): ForgeQE measures which existing tests already cover the run, then reuses one that covers it all, extends one that covers most, or writes a new test, and asks the user when that is not clear. new, extend, reuse, update: only when the user said which (\"extend the login test\", \"write a separate test\", \"update it\" after a reused test failed).",
164
+ },
165
+ test_file: { type: "string", description: "With mode extend, reuse, or update: the existing test file (from the user, or from an earlier write_test result)" },
99
166
  },
100
167
  },
101
168
  async run(args, ctx) {
@@ -108,6 +175,12 @@ export const COMMAND_TOOLS: McpTool[] = [
108
175
  const t0 = Date.now()
109
176
  const repo = inCwd(ctx, args.repo)
110
177
  const goal = (readJson(path.join(runDir, "run.json")) as { goal?: string } | null)?.goal || ""
178
+ // browser_run found an earlier test for this flow and it failed: Scribe updates it rather than adding another.
179
+ const existingTest = readJson(path.join(runDir, EXISTING_TEST_FILE)) as ExistingTest | null
180
+ // Otherwise: what the repository's tests already cover of this run decides reuse, extend, or new.
181
+ const chosen = existingTest?.testFile ? { target: undefined } : await chooseTarget(runDir, repo, args, ctx)
182
+ if ("cancelled" in chosen) return { status: "cancelled", notes: "The user cancelled before Scribe started; nothing was changed." }
183
+ const target = chosen.target
111
184
  ctx.emit?.({ type: "code-start", runDir, repo, goal })
112
185
  let r
113
186
  try {
@@ -119,6 +192,8 @@ export const COMMAND_TOOLS: McpTool[] = [
119
192
  onEvent: (event) => ctx.emit?.({ type: "agent", event }),
120
193
  open: ctx.openSession as never,
121
194
  runTest: ctx.runTest,
195
+ ...(existingTest?.testFile ? { existingTest } : {}),
196
+ ...(target ? { target } : {}),
122
197
  })
123
198
  } catch (err) {
124
199
  ctx.emit?.({ type: "code-done", error: (err as Error).message, ms: Date.now() - t0 })
@@ -136,6 +211,7 @@ export const COMMAND_TOOLS: McpTool[] = [
136
211
  notes: r.notes || undefined,
137
212
  browsers: r.browsers,
138
213
  review: r.review,
214
+ ...(r.existing ? { existing: r.existing } : {}),
139
215
  output: r.status === "passed" ? undefined : clip(r.output, 4000),
140
216
  }
141
217
  },
package/src/mcp/tools.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, writeFileSync } from "node:fs"
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
2
2
  import path from "node:path"
3
3
 
4
4
  import type { AgentEvent, CodeEvent, CodeResult, ReplayMode, RunResult } from "../types.ts"
@@ -16,6 +16,7 @@ import { buildHandoff } from "../code/handoff.ts"
16
16
  import { latestRunDir, runCodeAgent } from "../code/index.ts"
17
17
  import type { OpencodeSession } from "../code/runtime.ts"
18
18
  import { runTestCommand } from "../code/testrun.ts"
19
+ import { EXISTING_TEST_FILE, findWrittenTest } from "../code/notes.ts"
19
20
  import { browserProvider, type AppConfig } from "../config.ts"
20
21
  import { clearMemory, memoryFilesFor, memoryFlows, memoryOverview, memoryShow } from "../memory.ts"
21
22
  import { executeRun, runDirectory } from "../run.ts"
@@ -329,6 +330,59 @@ function devicesFor(args: Record<string, unknown>, ctx: ToolContext): string[] {
329
330
  return [...new Set(devices)]
330
331
  }
331
332
 
333
+ /**
334
+ * Runs the test an earlier job wrote for this goal, if there is one. `passed`: the tool's answer, the test having
335
+ * passed in a browser. `failed`: the test to update, for write_test. Null when there is no such test.
336
+ */
337
+ async function existingTestFirst(goal: string, runDir: string, ctx: ToolContext): Promise<{ passed?: Record<string, unknown>; failed?: { testFile: string; command: string; output: string; why: string } } | null> {
338
+ const cfg = ctx.config
339
+ if (!cfg.memory.enabled) return null
340
+ let repo = ctx.cwd
341
+ try {
342
+ repo = realpathSync(ctx.cwd)
343
+ } catch {}
344
+ const known = findWrittenTest(cfg.memory.dir, repo, goal, (f) => existsSync(path.resolve(repo, f)))
345
+ if (!known) return null
346
+ const emit = ctx.emit || (() => {})
347
+ emit({ type: "info", text: `This flow already has a test, ${known.test_file} (written ${known.at.slice(0, 10)}): ForgeQE runs it first` })
348
+ mkdirSync(runDir, { recursive: true })
349
+ const run = await (ctx.runTest || runTestCommand)({
350
+ command: known.command,
351
+ cwd: repo,
352
+ timeoutSeconds: cfg.code.testTimeoutSeconds,
353
+ signal: ctx.signal,
354
+ hide: privateEnvNames(cfg),
355
+ save: path.join(runDir, "existing-test"),
356
+ })
357
+ if (ctx.signal?.aborted) return null
358
+ const secs = Math.round(run.ms / 1000)
359
+ if (run.ok && run.browsers.length) {
360
+ emit({
361
+ type: "card",
362
+ card: {
363
+ status: "ok",
364
+ title: "Existing test passed",
365
+ stats: `${secs}s · no browser run or new test needed`,
366
+ body: [`Test ${known.test_file}`, `Command ${known.command}`, `Browser ${run.browsers.join(", ")}`, `Written ${known.at.slice(0, 10)} for the same flow`],
367
+ },
368
+ })
369
+ return {
370
+ passed: {
371
+ status: "passed",
372
+ reused_test: known.test_file,
373
+ written_for: known.goal,
374
+ command: known.command,
375
+ browsers: run.browsers,
376
+ seconds: secs,
377
+ note: "An earlier job wrote a test for this flow (written_for) and it passed as it is, in a real browser: no browser run and no new test were needed. If the user's request asks for something written_for does not check, call browser_run again with fresh: true; otherwise say in one or two lines that the existing test passed.",
378
+ },
379
+ }
380
+ }
381
+ const why = run.ok ? "It exits 0 but opened no browser." : `It fails now (${run.exitCode === null ? "timed out" : `exit ${run.exitCode}`}): the application or the page changed since it was written.`
382
+ emit({ type: "info", text: `${known.test_file} ${run.ok ? "opened no browser" : "fails now"}: ForgeQE runs the flow in the browser, and Scribe updates that test instead of writing a new one` })
383
+ return { failed: { testFile: known.test_file, command: known.command, output: run.output, why } }
384
+ }
385
+
332
386
  export const TOOLS: McpTool[] = [
333
387
  {
334
388
  name: "browser_run",
@@ -362,6 +416,11 @@ export const TOOLS: McpTool[] = [
362
416
  description:
363
417
  "test: a user flow of the app under test, to verify and turn into an automated test. task: anything else done in a browser, such as looking up or linking work items, checking a relationship, or reading data from a tool; no test is written for it.",
364
418
  },
419
+ fresh: {
420
+ type: "boolean",
421
+ description:
422
+ "true: run in the browser even when an earlier job wrote a test for this goal. Only when the user asks for a new browser run or a new test; by default an existing test is run first and reused when it passes.",
423
+ },
365
424
  },
366
425
  required: ["goal", "purpose"],
367
426
  },
@@ -378,6 +437,10 @@ export const TOOLS: McpTool[] = [
378
437
  if (devices.length > 1) return runGoalOnDevices(goal, url, devices, args, ctx)
379
438
  const runDir = runDirectory(ctx.config.export.outDir)
380
439
  const emit = ctx.emit || (() => {})
440
+ // The same flow asked again: an earlier job's test for it runs first. Passing in a browser, it answers the goal
441
+ // in seconds, with no browser run and no new test; failing, the run goes ahead and Scribe updates that test.
442
+ const reuse = ctx.writeTests && args.purpose !== "task" && !args.fresh && !resumeFrom && devices.length <= 1 ? await existingTestFirst(goal, runDir, ctx) : null
443
+ if (reuse?.passed) return reuse.passed
381
444
  const t0 = Date.now()
382
445
  emit({ type: "run-start", goal, url })
383
446
  try {
@@ -406,7 +469,11 @@ export const TOOLS: McpTool[] = [
406
469
  return summary
407
470
  }
408
471
  if (ctx.writeTests && result.status === "complete" && result.driver !== "synthetic") {
409
- return { ...summary, next_step: `This session turns passed runs into tests: call write_test with run_dir ${runDir} now, before you reply.` }
472
+ if (reuse?.failed) writeFileSync(path.join(runDir, EXISTING_TEST_FILE), `${JSON.stringify(reuse.failed, null, 2)}\n`)
473
+ return {
474
+ ...summary,
475
+ next_step: `This session turns passed runs into tests: call write_test with run_dir ${runDir} now, before you reply.${reuse?.failed ? ` It updates the existing test ${reuse.failed.testFile} instead of writing a new one.` : ""}`,
476
+ }
410
477
  }
411
478
  return summary
412
479
  } catch (err) {
package/src/run.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { saveApiSteps } from "./api/fromSteps.ts"
1
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
3
  import { createRequire } from "node:module"
3
4
  import path from "node:path"
@@ -23,6 +24,8 @@ import { knowledgeBlock, readKnowledge, relevantKnowledge } from "./knowledge.ts
23
24
  export type ExecuteOptions = {
24
25
  config: AppConfig
25
26
  goal: string
27
+ /** The test case the run is for: its API steps are saved as that case's scenario. */
28
+ caseRef?: { caseId?: string; caseTitle?: string }
26
29
  browserProvider?: BrowserProvider
27
30
  /** A named device from the provider files (browser_devices); it also picks the provider. */
28
31
  browserDevice?: string
@@ -156,6 +159,13 @@ export async function executeRun(opts: ExecuteOptions): Promise<{ result: RunRes
156
159
  const withReport = (out: { result: RunResult; files: string[] }) => {
157
160
  out.result.cost = costOf(out.result.usage, opts.config.llm.price)
158
161
  applyQuality(opts.config, dir, out.result, opts.onEvent)
162
+ // API steps the run made in the browser's session, kept as a scenario the API tools run again without a browser.
163
+ try {
164
+ const api = saveApiSteps(opts.config, dir, opts.caseRef || {})
165
+ if (api) out.result.api = { id: api.id, dir: api.dir, calls: api.calls }
166
+ } catch (err) {
167
+ opts.onEvent?.({ type: "log", level: "warn", text: `API steps not saved as a scenario: ${(err as Error).message}` })
168
+ }
159
169
  logEvent("run.done", { out_dir: dir, status: out.result.status, summary: out.result.summary, usage: out.result.usage, memory: out.result.memory, ...(out.result.skills ? { skills: out.result.skills } : {}) })
160
170
  try {
161
171
  out.files.push(writeRunReport(dir, out.result, reportEnv(opts.config, { url: startUrl, headed: opts.headed, browser }), t0))
@@ -77,11 +77,28 @@ export function suiteUsage(results: CaseResult[], analysis?: { usage: Usage; cos
77
77
  return { usage, cost: priced ? cost : undefined }
78
78
  }
79
79
 
80
+ /**
81
+ * The suite's headline, saying what each part did: the cases, and the tests written from them. A suite whose cases
82
+ * all passed but whose test did not pass is not called failed with "1/1 passed" beside it.
83
+ */
84
+ export function suiteHeadline(s: Pick<SuiteResult, "results" | "stopped">): { mark: "✓" | "✗" | "■"; title: string; counts: string; ok: boolean } {
85
+ const c = counts(s.results)
86
+ const coded = s.results.filter((r) => r.code)
87
+ const testsOk = coded.filter((r) => r.code!.status === "passed").length
88
+ const casesOk = c.passed === c.total
89
+ const testsLine = coded.length ? ` · ${testsOk}/${coded.length} ${coded.length === 1 ? "test" : "tests"} passing` : ""
90
+ const countsLine = `${c.passed}/${c.total} ${c.total === 1 ? "case" : "cases"} passed${testsLine}`
91
+ if (s.stopped) return { mark: "■", title: "Suite stopped", counts: countsLine, ok: false }
92
+ if (!casesOk) return { mark: "✗", title: "Suite failed", counts: countsLine, ok: false }
93
+ if (testsOk < coded.length) return { mark: "✗", title: `${c.total === 1 ? "Case" : "Cases"} passed, ${coded.length - testsOk === 1 ? "test" : "tests"} not passing`, counts: countsLine, ok: false }
94
+ return { mark: "✓", title: "Suite passed", counts: countsLine, ok: true }
95
+ }
96
+
80
97
  /** The end-of-suite summary for the terminal. */
81
98
  export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
82
- const c = counts(s.results)
83
99
  const tokens = s.results.reduce((n, r) => n + r.tokens, 0)
84
- const head = `${s.stopped ? "■ Suite stopped" : c.passed === c.total ? "✓ Suite passed" : "✗ Suite failed"} · ${c.passed}/${c.total} passed · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
100
+ const h = suiteHeadline(s)
101
+ const head = `${h.mark} ${h.title} · ${h.counts} · ${duration(s.ms)} · ${(tokens / 1000).toFixed(1)}K tokens`
85
102
  const show = (f: string) => path.relative(cwd, f) || f
86
103
  const total = suiteUsage(s.results, s.analysis)
87
104
  const lines = [head, ""]
@@ -116,7 +133,8 @@ export function formatSuite(s: SuiteResult, cwd = process.cwd()): string {
116
133
  export function markdownSummary(s: SuiteResult): string {
117
134
  const c = counts(s.results)
118
135
  const cell = (t: string) => t.replace(/\|/g, "\\|").replace(/\s+/g, " ").trim()
119
- const head = s.stopped ? "■ Suite stopped" : c.passed === c.total ? "✓ Suite passed" : "✗ Suite failed"
136
+ const h = suiteHeadline(s)
137
+ const head = `${h.mark} ${h.title}`
120
138
  const rows = s.results.map((r) => {
121
139
  const test = r.code ? `${r.code.status}${r.code.testFile ? ` \`${cell(r.code.testFile)}\`` : ""}` : ""
122
140
  const steps = r.stepResults?.length && r.case.steps.length ? `${r.stepResults.filter((v) => v.status === "passed").length}/${r.case.steps.length}` : ""
@@ -140,7 +158,7 @@ export function markdownSummary(s: SuiteResult): string {
140
158
  ]
141
159
  : []
142
160
  return [
143
- `### ${head} · ${c.passed}/${c.total} passed · ${duration(s.ms)}`,
161
+ `### ${head} · ${h.counts} · ${duration(s.ms)}`,
144
162
  "",
145
163
  "| | Case | Title | Steps | Time | Result | Test |",
146
164
  "|---|---|---|---|---|---|---|",
package/src/suite/run.ts CHANGED
@@ -151,6 +151,7 @@ export async function runSuite(opts: SuiteOptions): Promise<SuiteResult> {
151
151
  const { result } = await execute({
152
152
  config,
153
153
  goal: goalFor(original),
154
+ caseRef: { caseId: original.id, caseTitle: original.title },
154
155
  browserProvider: opts.browserProvider,
155
156
  browserDevice: device,
156
157
  url: tc.startUrl || opts.url,
package/src/summary.ts CHANGED
@@ -25,6 +25,8 @@ export type RunSummary = {
25
25
  usage: string
26
26
  /** WCAG rules passed and failed, Core Web Vitals, and whether the budgets held. */
27
27
  quality?: string
28
+ /** The API steps saved as a reusable scenario: "2 calls saved as API scenario 20261013-…" and where. */
29
+ api?: string
28
30
  }
29
31
 
30
32
  export const STATUS_TITLE: Record<RunStatus, string> = {
@@ -80,6 +82,7 @@ export function buildSummary(opts: {
80
82
  failedChecks?: { expected: string; actual: string }[]
81
83
  quality?: QualityReport
82
84
  budgetExceeded?: string[]
85
+ api?: { id: string; dir: string; calls: number }
83
86
  }): RunSummary {
84
87
  const dir = opts.files.length ? path.relative(process.cwd(), path.dirname(opts.files[0])) || "." : ""
85
88
  const assertion = opts.outcome === "assertion_failed"
@@ -97,6 +100,7 @@ export function buildSummary(opts: {
97
100
  savedFiles: opts.files.map((f) => path.basename(f)),
98
101
  usage: opts.usage ? usageLine(opts.usage, opts.cost) : "",
99
102
  ...(opts.quality && qualityLine(opts.quality) ? { quality: qualityLine(opts.quality) } : {}),
103
+ ...(opts.api ? { api: `${opts.api.calls} ${opts.api.calls === 1 ? "call" : "calls"} saved as a reusable API scenario · ${path.relative(process.cwd(), opts.api.dir) || opts.api.dir}/` } : {}),
100
104
  }
101
105
  }
102
106
 
@@ -106,6 +110,7 @@ export function formatSummary(s: RunSummary): string {
106
110
  if (s.finalUrl) lines.push(`Ended on ${s.finalUrl}`)
107
111
  if (s.memory) lines.push(`Memory ${s.memory}`)
108
112
  if (s.quality) lines.push(`Quality ${s.quality}`)
113
+ if (s.api) lines.push(`API ${s.api}`)
109
114
  if (s.usage) lines.push(`Usage ${s.usage}`)
110
115
  if (s.savedDir) lines.push(`Saved ${s.savedDir}/ ${s.savedFiles.join(" · ")}`)
111
116
  return lines.join("\n")
package/src/tui/App.tsx CHANGED
@@ -893,6 +893,7 @@ export function App({ options }: { options: TuiOptions }) {
893
893
  failedChecks: r?.failedChecks,
894
894
  quality: r?.quality,
895
895
  budgetExceeded: r?.budgetExceeded,
896
+ api: r?.api,
896
897
  ms: e.ms,
897
898
  tokens: r ? r.usage.input + r.usage.output : 0,
898
899
  steps: stepRef.current,
@@ -6,7 +6,7 @@ import { createContext, useContext, type ReactNode } from "react"
6
6
 
7
7
  import type { CodeResult, RunStatus } from "../types.ts"
8
8
  import { CARD_MARK, type Card } from "../card.ts"
9
- import { counts } from "../suite/report.ts"
9
+ import { counts, suiteHeadline } from "../suite/report.ts"
10
10
  import { suiteExitCode, type SuiteResult } from "../suite/run.ts"
11
11
  import { STATUS_MARK, shortPath, type RunSummary } from "../summary.ts"
12
12
  import { BAR } from "./components.tsx"
@@ -192,6 +192,7 @@ export function SummaryCard({ summary }: { summary: RunSummary }) {
192
192
  {summary.finalUrl ? detail("Ended on", <span fg={theme.muted}>{summary.finalUrl}</span>) : null}
193
193
  {summary.memory ? detail("Memory", <span fg={theme.muted}>{summary.memory}</span>) : null}
194
194
  {summary.quality ? detail("Quality", <span fg={theme.muted}>{summary.quality}</span>) : null}
195
+ {summary.api ? detail("API", <Linked text={summary.api} color={theme.muted} lead={UNDER + 10} />) : null}
195
196
  {summary.savedDir ? (
196
197
  <text>
197
198
  <span fg={theme.faint}>{"Saved "}</span>
@@ -272,7 +273,7 @@ export function Row({ label, value, color = theme.muted }: { label: string; valu
272
273
  export const CASE_MARK = { passed: "✓", failed: "✗", error: "!", stopped: "■" } as const
273
274
 
274
275
  export function SuiteCard({ suite }: { suite: SuiteResult }) {
275
- const c = counts(suite.results)
276
+ const head = suiteHeadline(suite)
276
277
  const ok = !suite.stopped && suiteExitCode(suite) === 0
277
278
  const color = suite.stopped ? theme.warn : ok ? theme.ok : theme.err
278
279
  const tokens = suite.results.reduce((n, r) => n + r.tokens, 0)
@@ -283,9 +284,9 @@ export function SuiteCard({ suite }: { suite: SuiteResult }) {
283
284
  <Marked mark={MARK.item} color={color} gap>
284
285
  <text>
285
286
  <span fg={theme.text}>
286
- <strong>{suite.stopped ? "Suite stopped" : ok ? "Suite passed" : "Suite failed"}</strong>
287
+ <strong>{head.title}</strong>
287
288
  </span>
288
- <span fg={theme.faint}>{` ${c.passed}/${c.total} passed · ${formatDuration(suite.ms)} · ${formatTokens(tokens)} tokens`}</span>
289
+ <span fg={theme.faint}>{` ${head.counts} · ${formatDuration(suite.ms)} · ${formatTokens(tokens)} tokens`}</span>
289
290
  </text>
290
291
  </Marked>
291
292
  <Under first>
package/src/types.ts CHANGED
@@ -59,6 +59,8 @@ export type RunResult = {
59
59
  quality?: QualityReport
60
60
  /** Budgets this run exceeded: the run counts as failed, though its steps passed. */
61
61
  budgetExceeded?: string[]
62
+ /** The run's API steps, saved as a reusable API scenario: where, and how many calls. */
63
+ api?: { id: string; dir: string; calls: number }
62
64
  }
63
65
 
64
66
  /** Why a failed step failed, in the browser agent's judgement: see src/cases/failure.ts. */
@@ -116,6 +118,8 @@ export type CodeResult = {
116
118
  knownDefect?: boolean
117
119
  /** The job did not finish: what it wrote was moved to the needs-attention folder (see src/code/attention.ts). */
118
120
  attention?: { dir: string; files: string[]; patch?: string; readme: string }
121
+ /** The job worked from an existing test: ran it as it was, extended it, or updated it. */
122
+ existing?: "reused" | "extended" | "updated"
119
123
  }
120
124
 
121
125
  /** What an agent did to a file, for the shell's file list. */