@capagents/forgeqe 1.1.8 → 1.2.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/src/code/notes.ts CHANGED
@@ -73,3 +73,79 @@ export function notesSection(n: RepoNotes): string {
73
73
  lines.push("", "Start from these notes instead of reading the repository again: read only the files this test uses or changes, and read a file before you edit or reuse it, since it may have changed.")
74
74
  return lines.join("\n")
75
75
  }
76
+
77
+ /**
78
+ * Words that say how a goal is phrased, not what flow it is: "verify each value is accepted and remains present" and
79
+ * "verify the values are accepted" ask for the same thing. Left out when two goals are compared.
80
+ */
81
+ const FILLER = new Set(
82
+ "a an and the of in on to with within only each every any all then this that is are be as it its if for from by or not when also into page step steps result results report run test flow treat fail verify verifying check checks expected appears appear present remains accepted"
83
+ .split(" "),
84
+ )
85
+
86
+ /** A goal's content words, endings off ("toggles", "toggled" are one), for telling the same flow from another. */
87
+ function goalWords(goal: string): Set<string> {
88
+ return new Set(
89
+ goal
90
+ .toLowerCase()
91
+ .replace(/https?:\/\/(www\.)?/g, "")
92
+ .split(/[^a-z0-9.@-]+/)
93
+ .map((w) => w.replace(/^[.-]+|[.-]+$/g, ""))
94
+ .filter((w) => w.length > 1 && !FILLER.has(w))
95
+ .map((w) => w.replace(/(ing|ed|es|s)$/, "")),
96
+ )
97
+ }
98
+
99
+ /** The website a goal names, if any: the same steps on another site are another flow. */
100
+ function goalHost(goal: string): string {
101
+ return /https?:\/\/(?:www\.)?([^/\s,)]+)/i.exec(goal)?.[1]?.toLowerCase() || ""
102
+ }
103
+
104
+ /**
105
+ * How alike two goals are, 0 to 1: the content words they share, over the words they have (Dice). Measured on real
106
+ * goals ForgeQE's agent wrote: the same flow reworded scores 0.88; a shorter flow on the same page 0.59, and the same
107
+ * flow with a different final check 0.67.
108
+ */
109
+ export function goalSimilarity(a: string, b: string): number {
110
+ const [ha, hb] = [goalHost(a), goalHost(b)]
111
+ if (ha && hb && ha !== hb) return 0
112
+ const x = goalWords(a)
113
+ const y = goalWords(b)
114
+ if (!x.size || !y.size) return 0
115
+ let shared = 0
116
+ for (const w of x) if (y.has(w)) shared++
117
+ return (2 * shared) / (x.size + y.size)
118
+ }
119
+
120
+ /** Goals this alike are the same flow asked again; ForgeQE's agent rewords a goal from one request to the next. */
121
+ export const SAME_GOAL = 0.8
122
+
123
+ /**
124
+ * Whether `goal` asks again for the flow an earlier test was written for. Rewording adds words and keeps the earlier
125
+ * ones; another flow drops what the earlier goal checked ("pie chart visible" becoming "list can be reordered"), so at
126
+ * most one of the earlier goal's content words may be missing, and the two must be alike overall.
127
+ */
128
+ export function sameFlow(earlier: string, goal: string): boolean {
129
+ if (goalSimilarity(earlier, goal) < SAME_GOAL) return false
130
+ const now = goalWords(goal)
131
+ let dropped = 0
132
+ for (const w of goalWords(earlier)) if (!now.has(w)) dropped++
133
+ return dropped <= 1
134
+ }
135
+
136
+ /**
137
+ * The test an earlier job wrote in this repository for the same goal, when its file is still there: the newest of the
138
+ * closest matches. Notes of any age count here, since the test file itself is what is checked.
139
+ */
140
+ export function findWrittenTest(memoryDir: string, repo: string, goal: string, exists: (file: string) => boolean): WrittenTest | null {
141
+ const tests = load(memoryDir).repos[repo]?.tests || []
142
+ let best: { test: WrittenTest; score: number } | null = null
143
+ for (const t of tests) {
144
+ const score = goalSimilarity(t.goal, goal)
145
+ if (sameFlow(t.goal, goal) && exists(t.test_file) && (!best || score > best.score || (score === best.score && t.at > best.test.at))) best = { test: t, score }
146
+ }
147
+ return best?.test || null
148
+ }
149
+
150
+ /** Where browser_run leaves the earlier test for a goal that failed, so write_test updates it instead of adding one. */
151
+ export const EXISTING_TEST_FILE = "existing-test.json"
@@ -74,6 +74,49 @@ export function preparePrompt(goal: string, checking = true): string {
74
74
  /** A test this case already has, which now fails: the job updates it instead of writing a new one. */
75
75
  export type ExistingTest = { testFile: string; command: string; output: string; why: string }
76
76
 
77
+ /**
78
+ * An existing test the job works from, chosen from measured coverage (or by the person): `reuse` it as it is, or
79
+ * `extend` it with only the steps and checks it lacks.
80
+ */
81
+ export type TestTarget = { mode: "reuse" | "extend" | "update"; testFile: string; test: string; line: number; covered: string[]; missing: string[]; why: string }
82
+
83
+ function targetSection(t: TestTarget): string[] {
84
+ const list = (items: string[]) => items.map((i) => `- ${i}`)
85
+ if (t.mode === "update") {
86
+ return [
87
+ "## Update the existing test, do not write a new one",
88
+ "",
89
+ `The user asked to update ${t.test} (${t.testFile}:${t.line}) so it matches the verified run below. Change only what no longer matches the run (a step, a locator, an expected value); keep its other steps and assertions. Do not create a new test file.`,
90
+ "Do not change what an existing page-object member or method does for the other tests that use it (code_references shows them); add a member instead, or keep its behaviour for them, and say so in notes.",
91
+ "",
92
+ ]
93
+ }
94
+ if (t.mode === "reuse") {
95
+ return [
96
+ "## The existing test that already covers this flow",
97
+ "",
98
+ `ForgeQE's code graph found that ${t.test} (${t.testFile}:${t.line}) already reaches every element of this run (${t.why}). Do not write or change any test or page object.`,
99
+ "Find the command that runs only that test (from your notes on this repository, or the repository's test setup), and give the structured result with that test_file, test_name, and command. ForgeQE runs it. If it fails, ForgeQE stops and asks the user before anything is changed.",
100
+ "",
101
+ ]
102
+ }
103
+ return [
104
+ "## Extend the existing test, do not write a new one",
105
+ "",
106
+ `ForgeQE's code graph found that ${t.test} (${t.testFile}:${t.line}) already covers most of this flow (${t.why}). Extend that test: add only what it lacks, in the order of the flow, through the same page objects and helpers. Keep every step and assertion it has. Do not create a new test file.`,
107
+ "",
108
+ "It already covers:",
109
+ ...list(t.covered),
110
+ "",
111
+ "Add only these:",
112
+ ...list(t.missing),
113
+ "",
114
+ "When the missing steps start from a different place than the test does, add one test method next to it in the same class, reusing its setup and helpers, instead of a new file.",
115
+ "Add page-object members for elements the repository does not locate yet. Do not change what an existing member or method does: other tests use it (code_references shows which). When one must change, keep its behaviour for those tests and say so in notes.",
116
+ "",
117
+ ]
118
+ }
119
+
77
120
  function existingSection(e: ExistingTest): string[] {
78
121
  return [
79
122
  "## The test this case already has",
@@ -91,7 +134,7 @@ function existingSection(e: ExistingTest): string[] {
91
134
  ]
92
135
  }
93
136
 
94
- export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest } = {}): string {
137
+ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; liveOn?: boolean; notes?: string; profile?: string; existing?: ExistingTest; target?: TestTarget } = {}): string {
95
138
  const live = opts.liveBrowserDir
96
139
  ? opts.liveOn
97
140
  ? `Your live_browser_* tools are on because the replay failed at a step; use them on that step's page. They save snapshots and screenshots in ${opts.liveBrowserDir}.`
@@ -100,9 +143,15 @@ export function firstPrompt(handoff: Handoff, opts: { liveBrowserDir?: string; l
100
143
  return [
101
144
  opts.existing
102
145
  ? `Update the existing test for the flow below so it passes again. When it is updated, give the structured result; ForgeQE then runs it.`
103
- : `Write a test in this repository for the flow below, reusing the repository's existing test code. When it is written, give the structured result; ForgeQE then runs it.`,
146
+ : opts.target?.mode === "reuse"
147
+ ? `An existing test already covers the flow below. Report how to run it; change nothing. ForgeQE then runs it.`
148
+ : opts.target?.mode === "extend"
149
+ ? `Extend the existing test named below with what the flow below adds. When it is extended, give the structured result; ForgeQE then runs it.`
150
+ : opts.target?.mode === "update"
151
+ ? `Update the existing test named below to match the flow below. When it is updated, give the structured result; ForgeQE then runs it.`
152
+ : `Write a test in this repository for the flow below, reusing the repository's existing test code. When it is written, give the structured result; ForgeQE then runs it.`,
104
153
  "",
105
- ...(opts.existing ? existingSection(opts.existing) : []),
154
+ ...(opts.existing ? existingSection(opts.existing) : opts.target ? targetSection(opts.target) : []),
106
155
  ...(opts.profile ? [opts.profile, ""] : []),
107
156
  ...(opts.notes ? [opts.notes, ""] : []),
108
157
  handoff.markdown,
@@ -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
package/src/detail.ts CHANGED
@@ -27,7 +27,8 @@ export function shows(event: AgentEvent | CodeEvent, detail: Detail): boolean {
27
27
  case "memory":
28
28
  return false
29
29
  case "code":
30
- if (event.kind === "info" || event.kind === "test") return true
30
+ // Milestones, and the exchange with the reviewer: why a test was rejected and what Scribe fixes.
31
+ if (event.kind === "info" || event.kind === "test" || event.kind === "review" || event.kind === "attempt" || event.kind === "answer") return true
31
32
  if (event.kind === "tool") return !event.ok || WRITES_FILE.test(event.text)
32
33
  return false
33
34
  default:
@@ -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/print.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { cardText } from "./card.ts"
2
+ import { answerText, attemptCard, reviewCard } from "./code/exchange.ts"
1
3
  import { briefText, shows, stepIntent, type Detail } from "./detail.ts"
2
4
  import type { AgentEvent } from "./types.ts"
3
5
 
@@ -45,6 +47,13 @@ export function formatEvent(event: AgentEvent, color = true, detail: Detail = "b
45
47
  return paint(c.cyan, ` ${event.text}`)
46
48
  case "test":
47
49
  return event.ok ? paint(c.green, `✓ ${event.text}`) : paint(c.red, `✗ ${event.text}`)
50
+ // The reviewer's verdict and Scribe's retries, in full: why it was rejected, and what is being fixed.
51
+ case "review":
52
+ return paint(event.approved ? c.green : c.red, cardText(reviewCard(event)))
53
+ case "attempt":
54
+ return paint(c.magenta, cardText(attemptCard(event)))
55
+ case "answer":
56
+ return paint(c.cyan, ` ${answerText(event)}`)
48
57
  default:
49
58
  return null
50
59
  }
package/src/tui/App.tsx CHANGED
@@ -30,10 +30,11 @@ import { initWorkspace } from "../init.ts"
30
30
  import { ShortcutsPanel } from "./shortcuts.tsx"
31
31
  import { ago, getStarted, projectLine, setupSentence, type Fact, type GetStarted, recentWork, startActions, type RecentItem, type StartAction } from "./landing.ts"
32
32
  import { TIPS, formatDuration, formatTokens, packageVersion, shortCwd, theme } from "./theme.ts"
33
- import { emptyProduced, Line, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
33
+ import { emptyProduced, Line, LineRoom, Produced, producedCard, settleCall, sinceRequest, TestsSetting, TranscriptLine, trimLines } from "./transcript.tsx"
34
34
  import { Dialog, dialogSpec } from "./dialogs.ts"
35
35
  import { activityFromLog, clip, modelLabel } from "./text.ts"
36
36
  import { featuresText } from "../capabilities/render.ts"
37
+ import { answerText, attemptCard, reviewCard } from "../code/exchange.ts"
37
38
  import { Palette, paletteItems, searchPalette, SuggestList, type PaletteItem } from "./palette.tsx"
38
39
  import { commandSuggestions, complete, lookupWorkItem, recentRuns, referenceSuggestions, repoFiles, triggerAt, type Suggestion, type WorkItem } from "./suggest.ts"
39
40
  import { initialChoice, REFINE_CHOICES, RefinePanel, type RefineChoice, type RefineState } from "./refine-panel.tsx"
@@ -988,6 +989,17 @@ export function App({ options }: { options: TuiOptions }) {
988
989
  else if (ev.kind === "say") record("thought", ev.text, shown)
989
990
  else if (ev.kind === "tool") pushTool(ev.text, ev.ok, ev.id, shown)
990
991
  else if (ev.kind === "test") record(ev.ok ? "ok" : "err", ev.text, shown)
992
+ // The exchange with the reviewer: its verdict and Scribe's retries as cards in the conversation, each with a
993
+ // line in Activity, so it is clear why a test was rejected and what is being fixed.
994
+ else if (ev.kind === "review") {
995
+ const card = reviewCard(ev)
996
+ pushLine({ kind: "card", text: card.title, card })
997
+ record(ev.approved ? "ok" : "err", `Review, attempt ${ev.attempt}/${ev.max}: ${ev.approved ? "approved" : `changes needed (${ev.issues.length})`} · ${clip(ev.summary, 140)}`, false)
998
+ } else if (ev.kind === "attempt") {
999
+ const card = attemptCard(ev)
1000
+ pushLine({ kind: "card", text: card.title, card })
1001
+ record("code", `Attempt ${ev.attempt}/${ev.max}: ${ev.why}${ev.fixing.length ? ` · fixing ${ev.fixing.length}` : ""}`, false)
1002
+ } else if (ev.kind === "answer") record("code", answerText(ev), true)
991
1003
  }
992
1004
  if (aborted()) return
993
1005
  const who = codingRef.current ? "Coding agent" : "Agent"
@@ -995,6 +1007,8 @@ export function App({ options }: { options: TuiOptions }) {
995
1007
  else if (ev.kind === "tool") setActivity(`${who}: ${clip(ev.text, 60)}`)
996
1008
  else if (ev.kind === "info" && ev.text.startsWith("ForgeQE runs the test")) setActivity("ForgeQE is running the new test…")
997
1009
  else if (ev.kind === "say") setActivity(`${who} is working…`)
1010
+ else if (ev.kind === "review") setActivity(ev.approved ? "The reviewer approved the test" : `Scribe is fixing ${ev.issues.length} review ${ev.issues.length === 1 ? "issue" : "issues"}…`)
1011
+ else if (ev.kind === "attempt") setActivity(`Scribe, attempt ${ev.attempt} of ${ev.max}: ${clip(ev.why, 50)}…`)
998
1012
  return
999
1013
  }
1000
1014
  case "code-start": {
@@ -1626,7 +1640,7 @@ export function App({ options }: { options: TuiOptions }) {
1626
1640
  const startRows = loading ? 1 + 1 + 4 : offer.length ? 1 + 1 + offer.length : 0
1627
1641
  const recentRows = recent.length ? 1 + 1 + recent.length : 0
1628
1642
  const setupRows = loading || open.length ? 2 : 0
1629
- const fixed = 5 + 2 + 1 + 3 + 1 + 1 + 1 // logo, project line, gap, prompt border, buttons, footer, one row to spare
1643
+ const fixed = 4 + 2 + 1 + 3 + 1 + 1 + 1 // logo and byline, project line, gap, prompt border, buttons, footer, one row to spare
1630
1644
  const room = height - fixed
1631
1645
  const want = Math.max(5, Math.min(promptLines, room - 1))
1632
1646
  // Refine or a question in the prompt's place takes the room the sections would: they are for starting, not deciding.
@@ -1661,7 +1675,7 @@ export function App({ options }: { options: TuiOptions }) {
1661
1675
  </box>
1662
1676
  <box style={{ height: 1, flexShrink: 0 }} />
1663
1677
  <box style={{ flexDirection: "column", width: promptWidth, flexShrink: 0 }}>
1664
- {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 5 - 2 - 1 - 1 - 1)}
1678
+ {renderControls(promptWidth, "Describe what you want to test…", [5, maxRows], height - 4 - 2 - 1 - 1 - 1)}
1665
1679
  {showStart ? (
1666
1680
  <box style={{ flexDirection: "column", marginTop: 1, height: startRows - 1, flexShrink: 0 }}>
1667
1681
  {heading(offerTitle)}
@@ -1813,10 +1827,12 @@ export function App({ options }: { options: TuiOptions }) {
1813
1827
  )
1814
1828
  }
1815
1829
 
1816
- const transcript = (every: Line[], style: Record<string, unknown>) => {
1830
+ /** `room`: the columns a line of the conversation has (less the scrollbar), so long links are drawn to fit. */
1831
+ const transcript = (every: Line[], style: Record<string, unknown>, room: number) => {
1817
1832
  // Quiet lines (reads, searches, calls in progress) are the Activity panel's; the conversation stays readable.
1818
1833
  const all = every.filter((l) => !l.quiet)
1819
1834
  return (
1835
+ <LineRoom.Provider value={Math.max(20, room - 1)}>
1820
1836
  <scrollbox ref={scrollRef} stickyScroll stickyStart="bottom" style={{ flexGrow: 1, ...style }} focused={false}>
1821
1837
  {all.map((line, i) => (
1822
1838
  <TranscriptLine key={line.id} line={line} prev={all[i - 1]} />
@@ -1832,6 +1848,7 @@ export function App({ options }: { options: TuiOptions }) {
1832
1848
  </box>
1833
1849
  ) : null}
1834
1850
  </scrollbox>
1851
+ </LineRoom.Provider>
1835
1852
  )
1836
1853
  }
1837
1854
  // A long request gets room: the box grows with the text up to nearly half the screen, then scrolls.
@@ -1847,7 +1864,7 @@ export function App({ options }: { options: TuiOptions }) {
1847
1864
  <box style={{ flexDirection: "row", height: height - 1, flexShrink: 0, paddingTop: 1 }}>
1848
1865
  <box onMouseDown={() => navRef.current.focus !== "chat" && setNav((n) => ({ ...n, focus: "chat" }))} style={{ flexDirection: "column", width: leftWidth, height: height - 2, paddingLeft: 2, paddingRight: 1 }}>
1849
1866
  {infoPanels(leftWidth - 3)}
1850
- {transcript(chatLines, {})}
1867
+ {transcript(chatLines, {}, leftWidth - 3)}
1851
1868
  <box style={{ flexDirection: "column", flexShrink: 0 }}>{controls(leftWidth - 3)}</box>
1852
1869
  </box>
1853
1870
  <box style={{ flexDirection: "column", width: width - leftWidth, height: height - 2, paddingLeft: 1, paddingRight: 2 }}>
@@ -1883,7 +1900,7 @@ export function App({ options }: { options: TuiOptions }) {
1883
1900
  }
1884
1901
  return (
1885
1902
  <box style={{ flexDirection: "column", flexGrow: 1 }}>
1886
- {transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 })}
1903
+ {transcript(lines, { paddingLeft: 2, paddingRight: 2, paddingTop: 1 }, width - 4)}
1887
1904
  <box style={{ flexDirection: "column", paddingLeft: 2, paddingRight: 2, flexShrink: 0 }}>{controls(promptWidth)}</box>
1888
1905
  <Footer left={shortCwd()} right={footerRight} width={width} />
1889
1906
  {paletteOverlay}
@@ -18,14 +18,15 @@ export const BAR = {
18
18
  }
19
19
 
20
20
  export function Logo() {
21
- const name = logoRows("forge")
21
+ // One wordmark in capitals, FORGE in the text colour and QE in the accent: one height, weight, and baseline.
22
+ const name = logoRows("FORGE")
22
23
  const suffix = logoRows("QE")
23
24
  return (
24
25
  <box style={{ flexDirection: "column", alignItems: "flex-end", flexShrink: 0 }}>
25
26
  {name.map((row, i) => (
26
27
  <text key={i}>
27
- <span fg={theme.faint}>{row}</span>
28
- <span fg={theme.text}>{` ${suffix[i]}`}</span>
28
+ <span fg={theme.text}>{row}</span>
29
+ <span fg={theme.accent}>{` ${suffix[i]}`}</span>
29
30
  </text>
30
31
  ))}
31
32
  <text fg={theme.muted}>by Capgemini</text>
package/src/tui/text.ts CHANGED
@@ -31,3 +31,19 @@ export function activityFromLog(text: string): string {
31
31
  if (text.startsWith("ForgeQE engine ·")) return "Starting the browser…"
32
32
  return clip(text, 70)
33
33
  }
34
+
35
+ /**
36
+ * A link's text shortened to `max` columns, so it never wraps into what reads as two links. The start and the last
37
+ * part (the file, or the folder of a path ending in "/") are kept: ".forgeqe/reports/open-defect-rep…/report.html".
38
+ * The link itself keeps its whole target; only what is drawn is shorter.
39
+ */
40
+ export function shortLink(text: string, max: number): string {
41
+ if (text.length <= max || max < 8) return text
42
+ const trimmed = text.replace(/\/$/, "")
43
+ const slash = trimmed.lastIndexOf("/")
44
+ const tail = slash > 0 ? text.slice(slash) : ""
45
+ // The last part, whole, when it leaves room for a useful start; otherwise both ends of the text.
46
+ if (tail && tail.length <= max - 10) return `${text.slice(0, max - tail.length - 1)}…${tail}`
47
+ const end = Math.floor((max - 1) / 2)
48
+ return `${text.slice(0, max - 1 - end)}…${text.slice(text.length - end)}`
49
+ }