@flowriki/agent 0.1.0-beta.33 → 0.1.0-beta.34

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.
@@ -6,7 +6,7 @@ import os from "os";
6
6
  import path from "path";
7
7
  import { createRequire } from "module";
8
8
  import { spawnSync } from "child_process";
9
- var AGENT_VERSION = "0.1.0-beta.33";
9
+ var AGENT_VERSION = "0.1.0-beta.34";
10
10
  async function prepareBrowsers() {
11
11
  if (browsersReady()) {
12
12
  return { ok: true, already: true, message: "chromium is already installed" };
package/dist/cli.js CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  runtimeValue,
22
22
  start,
23
23
  status
24
- } from "./chunk-3UUCI66O.js";
24
+ } from "./chunk-7OODSC7P.js";
25
25
 
26
26
  // src/cli.ts
27
27
  import { parseArgs } from "util";
@@ -39,7 +39,7 @@ async function handle(message, tools, info) {
39
39
  const version = PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0];
40
40
  return reply({
41
41
  protocolVersion: version,
42
- capabilities: { tools: { listChanged: false } },
42
+ capabilities: { tools: { listChanged: true } },
43
43
  serverInfo: { name: info.name, version: info.version },
44
44
  instructions: info.instructions
45
45
  });
@@ -95,6 +95,10 @@ function serve(tools, info, io = {}) {
95
95
  const task = handle(message, tools, info).then((answer) => {
96
96
  if (answer) output.write(`${JSON.stringify(answer)}
97
97
  `);
98
+ if (message.method === "notifications/initialized") {
99
+ output.write(`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}
100
+ `);
101
+ }
98
102
  });
99
103
  pending.add(task);
100
104
  void task.finally(() => pending.delete(task));
@@ -168,16 +172,36 @@ var ServiceClient = class {
168
172
  { method: "DELETE" }
169
173
  );
170
174
  }
171
- saveFlow(projectId, title, flow) {
175
+ saveFlow(projectId, title, flow, where = {}) {
172
176
  return this.call(
173
177
  `/api/agent/flows?projectId=${encodeURIComponent(projectId)}`,
174
- { method: "POST", body: JSON.stringify({ title, flow }) }
178
+ { method: "POST", body: JSON.stringify({ title, flow, ...where }) }
179
+ );
180
+ }
181
+ /** Gather Claude's open branches here into one. */
182
+ gatherChanges(projectId, opts = {}) {
183
+ return this.call(
184
+ `/api/agent/changes/gather?projectId=${encodeURIComponent(projectId)}`,
185
+ { method: "POST", body: JSON.stringify(opts) }
175
186
  );
176
187
  }
177
188
  };
178
189
 
179
190
  // src/mcp/session.ts
180
191
  var MAX_TREE = 14e3;
192
+ var MAX_STEP_VIEW = 6e3;
193
+ function topLevelBlocks(tree) {
194
+ const blocks = [];
195
+ for (const line of tree.split("\n")) {
196
+ if (line.startsWith("- ") || blocks.length === 0) blocks.push([line]);
197
+ else blocks[blocks.length - 1].push(line);
198
+ }
199
+ const named = (b, kinds) => kinds.some((k) => b[0].startsWith(`- ${k}`));
200
+ return {
201
+ dialogs: blocks.filter((b) => named(b, ["dialog", "alertdialog"])).map((b) => b.join("\n")),
202
+ navigation: blocks.find((b) => named(b, ["navigation", "banner"]))?.join("\n") ?? null
203
+ };
204
+ }
181
205
  var STEP_TIMEOUT_MS = 1e4;
182
206
  var ACTIONS = ["click", "dblclick", "rightclick", "hover", "fill", "select", "check", "press", "navigate", "back", "forward"];
183
207
  var isAction = (value) => ACTIONS.includes(String(value));
@@ -238,6 +262,7 @@ var AuthoringSession = class {
238
262
  page = null;
239
263
  values = /* @__PURE__ */ new Map();
240
264
  resolvers = null;
265
+ lastTree = null;
241
266
  draft = null;
242
267
  catalog = null;
243
268
  get open() {
@@ -272,6 +297,7 @@ var AuthoringSession = class {
272
297
  this.catalog = catalog;
273
298
  this.draft = { ...input, steps: [] };
274
299
  this.values = /* @__PURE__ */ new Map();
300
+ this.lastTree = null;
275
301
  const base = this.baseUrl();
276
302
  this.run = await openRunSession({ steps: [], headless: this.opts.headless ?? false });
277
303
  this.page = this.run.page;
@@ -304,19 +330,52 @@ var AuthoringSession = class {
304
330
  }
305
331
  /** The page as Claude reads it: where it is, and what is on it by role and name. */
306
332
  async snapshot() {
333
+ return this.view(true);
334
+ }
335
+ /** WHAT CHANGED, after a step — not the whole page again.
336
+ *
337
+ * Every step used to answer with the full tree, which on a long storefront
338
+ * is twenty thousand characters repeated forty times to say "the box now
339
+ * has text in it". So a step answers with what it is about: an open dialog
340
+ * (with the navigation, which says who is signed in), or the lines that
341
+ * appeared and disappeared — and only after a real navigation, when most of
342
+ * the page is new, the page itself, capped. read_page is always the whole
343
+ * thing. */
344
+ async view(full) {
307
345
  const { page, draft } = this.requireOpen();
308
346
  await page.waitForLoadState("domcontentloaded", { timeout: 1e4 }).catch(() => void 0);
309
- let tree = labelTree(
347
+ const tree = labelTree(
310
348
  await page.locator("body").ariaSnapshot({ timeout: 8e3 }).catch((e) => `(could not read the page: ${e.message})`),
311
349
  await fieldNames(page)
312
350
  );
313
- if (tree.length > MAX_TREE) tree = `${tree.slice(0, MAX_TREE)}
314
- \u2026 (cut \u2014 the page is long; act on what is above, or scroll with press "PageDown")`;
315
351
  const title = await page.title().catch(() => "");
316
- return `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
352
+ const head = `Page: ${page.url()}${title ? ` \u2014 "${title}"` : ""}
317
353
  Steps so far: ${draft.steps.length}
318
354
 
319
- ${tree}`;
355
+ `;
356
+ const before = this.lastTree;
357
+ this.lastTree = tree;
358
+ const cap = (t, max) => t.length > max ? `${t.slice(0, max)}
359
+ \u2026 (cut \u2014 read_page shows the whole page)` : t;
360
+ if (full || before === null) return head + cap(tree, MAX_TREE);
361
+ const { dialogs, navigation } = topLevelBlocks(tree);
362
+ if (dialogs.length) {
363
+ return head + cap([...navigation ? [navigation] : [], ...dialogs].join("\n"), MAX_STEP_VIEW) + "\n(only the open dialog and the navigation \u2014 read_page shows the whole page)";
364
+ }
365
+ const was = new Set(before.split("\n"));
366
+ const now = new Set(tree.split("\n"));
367
+ const added = tree.split("\n").filter((l) => !was.has(l));
368
+ const removed = before.split("\n").filter((l) => !now.has(l));
369
+ if (added.length === 0 && removed.length === 0) return head + "(the page did not change \u2014 read_page shows it)";
370
+ if (added.length + removed.length > 120) return head + cap(tree, MAX_STEP_VIEW);
371
+ return head + [
372
+ ...navigation ? [navigation, ""] : [],
373
+ added.length ? `Appeared:
374
+ ${added.join("\n")}` : "",
375
+ removed.length ? `Gone:
376
+ ${removed.join("\n")}` : "",
377
+ "(changes since the last step \u2014 read_page shows the whole page)"
378
+ ].filter(Boolean).join("\n");
320
379
  }
321
380
  /** Do one thing on the page, and keep it as a step if it worked. */
322
381
  async act(input) {
@@ -354,13 +413,13 @@ ${tree}`;
354
413
  } catch (error) {
355
414
  throw new Error(`That did not work, and was not kept: ${error.message.split("\n")[0]}
356
415
 
357
- ${await this.snapshot()}`);
416
+ ${await this.view(false)}`);
358
417
  }
359
418
  await this.settle();
360
419
  draft.steps.push(step);
361
420
  return `Step ${draft.steps.length} kept: ${describe(step)}.
362
421
 
363
- ${await this.snapshot()}`;
422
+ ${await this.view(false)}`;
364
423
  }
365
424
  /** Check something about the page now, and keep the check if it holds. */
366
425
  async check(input) {
@@ -437,7 +496,7 @@ ${await this.snapshot()}`;
437
496
  draft.steps.push(step);
438
497
  return `Step ${draft.steps.length} kept: ${describe(step)}. It got "${got.value}" (${got.detail}). Use {{${step.helper.saveAs}}} as the value of a later step.` + (step.helper.then === "open" ? `
439
498
 
440
- ${await this.snapshot()}` : "");
499
+ ${await this.view(false)}` : "");
441
500
  }
442
501
  list() {
443
502
  const draft = this.draft;
@@ -473,24 +532,26 @@ ${this.list()}`;
473
532
  ${lines.join("\n")}`;
474
533
  }
475
534
  /** Save the draft as a flow on a new branch, for a person to review. */
476
- async save(title) {
535
+ async save(title, where = {}) {
477
536
  const { draft } = this.requireOpen();
478
537
  if (draft.steps.length === 0) throw new Error("there are no steps to save yet");
479
538
  if (!draft.steps.some((s) => s.type === "assert" || s.proposals.length > 0)) {
480
539
  throw new Error("the flow checks nothing \u2014 add at least one check (add_check) that proves it worked, then save");
481
540
  }
482
- const saved = await this.service.saveFlow(draft.projectId, title || `Write "${draft.name}" with Claude`, {
541
+ const saved = await this.service.saveFlow(draft.projectId, title || "Tests written with Claude", {
483
542
  name: draft.name,
484
543
  ...draft.featureId ? { featureId: draft.featureId } : { featureName: draft.featureName },
485
544
  startPath: draft.startPath,
486
545
  steps: draft.steps,
487
546
  ...draft.signIn ? { signIn: draft.signIn } : {}
488
- });
547
+ }, where);
489
548
  const review = `${this.service.origin}/projects/${draft.projectId}/changes/${saved.changeId}`;
549
+ const what = saved.replaced ? `Replaced the earlier "${draft.name}"` : `Saved "${draft.name}"`;
550
+ const where_ = saved.newBranch ? `on a new branch, ${saved.branch}` : `on Claude's working branch, ${saved.branch}`;
490
551
  const others = await this.service.agentChanges(draft.projectId).then((r) => r.changes.filter((c) => c.id !== saved.changeId)).catch(() => []);
491
552
  const note = others.length ? `
492
- Claude also has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 list_my_branches shows them, if one is now superseded.` : "";
493
- return `Saved "${draft.name}" on a new branch, ${saved.branch}. Nothing is published yet.
553
+ Claude has ${others.length} other open branch${others.length === 1 ? "" : "es"} on this project \u2014 gather_branches puts them into this one, for one review.` : "";
554
+ return `${what} ${where_}. Nothing is published yet.
494
555
  Review and publish it here: ${review}${note}`;
495
556
  }
496
557
  async close() {
@@ -537,11 +598,15 @@ How to write a good Flowriki test:
537
598
  a heading, a message, the URL. Saving is refused for a flow that checks nothing.
538
599
  7. Before saving, call replay_flow to run the whole flow from the start. If a
539
600
  step fails there, fix it (remove_step, act again) and replay again.
540
- 8. Save with save_flow and give the person the review link it returns.
541
- 9. Redoing a flow? Each save is a new branch, so clear the superseded one with
542
- list_my_branches and discard_branch \u2014 only after the person asks for it, or
543
- agrees, because it deletes the branch. Only Claude's own unedited branches
544
- can go this way; anything a person touched is theirs to delete.
601
+ 8. Save with save_flow and give the person the review link it returns. Saves go
602
+ to ONE working branch per project \u2014 everything you write here lands on it,
603
+ for one review. Saving a flow with the same name again replaces it there.
604
+ The catalog lists what is already on that branch: reuse its features, and
605
+ do not write a flow twice.
606
+ 9. Old branches of yours from before (one per flow)? gather_branches puts them
607
+ into one. discard_branch deletes one \u2014 only after the person asks for it or
608
+ agrees. Only Claude's own unedited branches can be gathered or deleted;
609
+ anything a person touched is theirs.
545
610
  Test data you invent (a new user's email or password for a sign-up) is fine to
546
611
  type. For anything that must be NEW on every run \u2014 a sign-up's email or
547
612
  username \u2014 use {{unique}}: it is different on every run and the same within
@@ -621,6 +686,12 @@ It is now the project these tools work on. Open it: ${service.origin}/projects/$
621
686
  Features:${c.features.length ? "" : " none yet"}`,
622
687
  ...c.features.map((f) => ` - ${f.name} (id ${f.id})${f.description ? ` \u2014 ${f.description}` : ""}
623
688
  ${flowsBy(f.id)}`),
689
+ ...c.draft ? [
690
+ `
691
+ On Claude's working branch "${c.draft.title}" (${c.draft.changeId}), not yet published:`,
692
+ ...c.draft.features.length ? [` New features: ${c.draft.features.map((f) => `${f.name} (id ${f.id})`).join(", ")}`] : [],
693
+ ...c.draft.flows.length ? c.draft.flows.map((f) => ` - ${f.name} (${f.steps} steps, feature ${f.featureId})`) : [" (no flows yet)"]
694
+ ] : [],
624
695
  `
625
696
  Sign-in profiles:${c.signIns.length ? "" : " none"}`,
626
697
  ...c.signIns.map((s) => ` - ${s.name} (profileId ${s.profileId}): ${s.accounts.map((a) => `${a.name} (accountId ${a.accountId})`).join(", ") || "no accounts yet"}`),
@@ -783,9 +854,20 @@ ${page}`);
783
854
  {
784
855
  name: "save_flow",
785
856
  title: "Save the flow to a branch",
786
- description: "Save the flow to a new branch in the project's repository, for a person to review and publish. Returns the review link. Refused if the flow checks nothing.",
787
- inputSchema: { type: "object", properties: { title: { type: "string", description: "The branch's title \u2014 what this change adds." } }, additionalProperties: false },
788
- run: async (args) => text(await session.save(str(args["title"])))
857
+ description: "Save the flow onto Claude's working branch for this project (made on the first save), for a person to review and publish. Saving the same name again replaces the earlier version there. Returns the review link. Refused if the flow checks nothing.",
858
+ inputSchema: {
859
+ type: "object",
860
+ properties: {
861
+ title: { type: "string", description: "The branch's title, used only when a new branch is started." },
862
+ branch: { type: "string", description: "Optional: a branch id (chg_\u2026) from list_my_branches to save onto instead." },
863
+ newBranch: { type: "boolean", description: "Start a separate branch instead of the working one. Only when the person asks for separate reviews." }
864
+ },
865
+ additionalProperties: false
866
+ },
867
+ run: async (args) => text(await session.save(str(args["title"]), {
868
+ ...str(args["branch"]) ? { changeId: str(args["branch"]) } : {},
869
+ ...args["newBranch"] === true ? { newBranch: true } : {}
870
+ }))
789
871
  },
790
872
  {
791
873
  name: "list_my_branches",
@@ -800,6 +882,30 @@ ${page}`);
800
882
  return text(changes.map((c) => `- ${c.id} \u2014 "${c.title}" (${new Date(c.createdAt).toISOString().slice(0, 16).replace("T", " ")}) ${service.origin}${c.path}`).join("\n"));
801
883
  }
802
884
  },
885
+ {
886
+ name: "gather_branches",
887
+ title: "Gather Claude's branches into one",
888
+ description: "Move the flows from Claude's open branches on this project into one branch, and delete the emptied ones \u2014 so the person reviews and publishes once. Only Claude's own, unedited branches; anything a person touched stops it.",
889
+ inputSchema: {
890
+ type: "object",
891
+ properties: {
892
+ into: { type: "string", description: "The branch to gather into (chg_\u2026). Default: Claude's oldest open branch." },
893
+ title: { type: "string", description: 'A title for the gathered branch, e.g. "Sign in and sign up tests".' },
894
+ projectId: { type: "string" }
895
+ },
896
+ additionalProperties: false
897
+ },
898
+ annotations: { destructiveHint: true },
899
+ run: async (args) => {
900
+ const projectId = await projectFor(args["projectId"]);
901
+ const done = await service.gatherChanges(projectId, {
902
+ ...str(args["into"]) ? { into: str(args["into"]) } : {},
903
+ ...str(args["title"]) ? { title: str(args["title"]) } : {}
904
+ });
905
+ catalogs.delete(projectId);
906
+ return text(`Gathered ${done.gathered.length} branch${done.gathered.length === 1 ? "" : "es"} into "${done.title}" (${done.changeId}). Review and publish it here: ${service.origin}${done.path}`);
907
+ }
908
+ },
803
909
  {
804
910
  name: "discard_branch",
805
911
  title: "Delete one of Claude's branches",
package/dist/engine.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  resolveCheckouts,
10
10
  start,
11
11
  status
12
- } from "./chunk-3UUCI66O.js";
12
+ } from "./chunk-7OODSC7P.js";
13
13
  export {
14
14
  AGENT_VERSION,
15
15
  browsersReady,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowriki/agent",
3
- "version": "0.1.0-beta.33",
3
+ "version": "0.1.0-beta.34",
4
4
  "description": "Run Flowriki UI tests on your own computer. Connects this machine to your Flowriki account.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",