@yoqa/runner 0.3.26 → 0.3.28

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 (2) hide show
  1. package/dist/index.js +1393 -162
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -16444,6 +16444,7 @@ var apps = sqliteTable("apps", {
16444
16444
  name: text("name").notNull(),
16445
16445
  prefix: text("prefix").notNull().default(""),
16446
16446
  context: text("context").notNull().default(""),
16447
+ knowledge: text("knowledge").notNull().default(""),
16447
16448
  iosBundleId: text("ios_bundle_id").notNull().default(""),
16448
16449
  iosAppStoreId: text("ios_app_store_id").notNull().default(""),
16449
16450
  androidApplicationId: text("android_application_id").notNull().default(""),
@@ -16500,6 +16501,7 @@ var runs = sqliteTable("runs", {
16500
16501
  buildId: text("build_id"),
16501
16502
  status: text("status").notNull(),
16502
16503
  executionMode: text("execution_mode").notNull().default("auto"),
16504
+ screenMode: text("screen_mode").notNull().default("tree"),
16503
16505
  error: text("error"),
16504
16506
  createdAt: integer("created_at").notNull(),
16505
16507
  startedAt: integer("started_at"),
@@ -16524,6 +16526,7 @@ var runSteps = sqliteTable("run_steps", {
16524
16526
  screenshotUri: text("screenshot_uri"),
16525
16527
  ok: integer("ok").notNull().default(0),
16526
16528
  latencyMs: integer("latency_ms").notNull().default(0),
16529
+ phasesJson: text("phases_json"),
16527
16530
  detail: text("detail"),
16528
16531
  command: text("command"),
16529
16532
  createdAt: integer("created_at").notNull()
@@ -16543,6 +16546,7 @@ function ensureSchema(sqlite) {
16543
16546
  name TEXT NOT NULL,
16544
16547
  prefix TEXT NOT NULL DEFAULT '',
16545
16548
  context TEXT NOT NULL DEFAULT '',
16549
+ knowledge TEXT NOT NULL DEFAULT '',
16546
16550
  ios_bundle_id TEXT NOT NULL DEFAULT '',
16547
16551
  ios_app_store_id TEXT NOT NULL DEFAULT '',
16548
16552
  android_application_id TEXT NOT NULL DEFAULT '',
@@ -16626,6 +16630,7 @@ function ensureSchema(sqlite) {
16626
16630
  build_id TEXT,
16627
16631
  status TEXT NOT NULL,
16628
16632
  execution_mode TEXT NOT NULL DEFAULT 'auto',
16633
+ screen_mode TEXT NOT NULL DEFAULT 'tree',
16629
16634
  error TEXT,
16630
16635
  created_at INTEGER NOT NULL,
16631
16636
  started_at INTEGER,
@@ -16652,6 +16657,7 @@ function ensureSchema(sqlite) {
16652
16657
  screenshot_uri TEXT,
16653
16658
  ok INTEGER NOT NULL DEFAULT 0,
16654
16659
  latency_ms INTEGER NOT NULL DEFAULT 0,
16660
+ phases_json TEXT,
16655
16661
  detail TEXT,
16656
16662
  command TEXT,
16657
16663
  created_at INTEGER NOT NULL
@@ -16670,9 +16676,12 @@ function ensureSchema(sqlite) {
16670
16676
  `);
16671
16677
  migrateProvidersTable(sqlite);
16672
16678
  migrateAppsPrefix(sqlite);
16679
+ migrateAppsKnowledge(sqlite);
16673
16680
  migrateCaseScripts(sqlite);
16674
16681
  migrateRunExecutionMode(sqlite);
16682
+ migrateRunScreenMode(sqlite);
16675
16683
  migrateRunCommands(sqlite);
16684
+ migrateRunStepPhases(sqlite);
16676
16685
  }
16677
16686
  function tableColumns(sqlite, table) {
16678
16687
  const rows = sqlite.query(`PRAGMA table_info(${table})`).all();
@@ -16701,6 +16710,12 @@ function migrateRunExecutionMode(sqlite) {
16701
16710
  addColumnIfMissing(sqlite, "run_tests", "execution_mode", "execution_mode TEXT", testCols);
16702
16711
  }
16703
16712
  }
16713
+ function migrateRunScreenMode(sqlite) {
16714
+ const runCols = tableColumns(sqlite, "runs");
16715
+ if (runCols.size > 0) {
16716
+ addColumnIfMissing(sqlite, "runs", "screen_mode", "screen_mode TEXT NOT NULL DEFAULT 'tree'", runCols);
16717
+ }
16718
+ }
16704
16719
  function migrateRunCommands(sqlite) {
16705
16720
  const testCols = tableColumns(sqlite, "run_tests");
16706
16721
  if (testCols.size > 0) {
@@ -16711,6 +16726,12 @@ function migrateRunCommands(sqlite) {
16711
16726
  addColumnIfMissing(sqlite, "run_steps", "command", "command TEXT", stepCols);
16712
16727
  }
16713
16728
  }
16729
+ function migrateRunStepPhases(sqlite) {
16730
+ const stepCols = tableColumns(sqlite, "run_steps");
16731
+ if (stepCols.size === 0)
16732
+ return;
16733
+ addColumnIfMissing(sqlite, "run_steps", "phases_json", "phases_json TEXT", stepCols);
16734
+ }
16714
16735
  function migrateProvidersTable(sqlite) {
16715
16736
  const existing = tableColumns(sqlite, "providers");
16716
16737
  if (existing.size === 0)
@@ -16793,6 +16814,12 @@ function migrateAppsPrefix(sqlite) {
16793
16814
  sqlite.run("UPDATE apps SET prefix = ? WHERE id = ?", [candidate, row.id]);
16794
16815
  }
16795
16816
  }
16817
+ function migrateAppsKnowledge(sqlite) {
16818
+ const existing = tableColumns(sqlite, "apps");
16819
+ if (existing.size === 0)
16820
+ return;
16821
+ addColumnIfMissing(sqlite, "apps", "knowledge", "knowledge TEXT NOT NULL DEFAULT ''", existing);
16822
+ }
16796
16823
  function getCatalogDbPath() {
16797
16824
  return process.env.YOQA_DB_PATH ?? DEFAULT_DB_PATH;
16798
16825
  }
@@ -16816,7 +16843,7 @@ function getCatalogDb() {
16816
16843
  // package.json
16817
16844
  var package_default = {
16818
16845
  name: "@yoqa/runner",
16819
- version: "0.3.26",
16846
+ version: "0.3.28",
16820
16847
  description: "Yoqa local runner \u2014 Appium + catalog HTTP API (Bun). Start with yoqa serve or the yoqa-runner bin.",
16821
16848
  license: "UNLICENSED",
16822
16849
  type: "module",
@@ -16836,7 +16863,7 @@ var package_default = {
16836
16863
  url: "git+https://github.com/Aris-ngoy/qa-agent.git",
16837
16864
  directory: "services/runner"
16838
16865
  },
16839
- homepage: "https://yoqa.mintlify.app/docs/cli",
16866
+ homepage: "https://yoqa.arisn.dev/docs/cli",
16840
16867
  bugs: {
16841
16868
  url: "https://github.com/Aris-ngoy/qa-agent/issues"
16842
16869
  },
@@ -16912,8 +16939,8 @@ function abortAllMjpegProxies() {
16912
16939
  // src/domains/devices/session.ts
16913
16940
  import { mkdir as mkdir3 } from "fs/promises";
16914
16941
  import { createServer as createServer2 } from "net";
16915
- import { homedir as homedir6 } from "os";
16916
- import { join as join7 } from "path";
16942
+ import { homedir as homedir7 } from "os";
16943
+ import { join as join8 } from "path";
16917
16944
  import { remote } from "webdriverio";
16918
16945
 
16919
16946
  // src/domains/devices/android-gestures.ts
@@ -17596,10 +17623,13 @@ function isUnsupportedCommandError(error) {
17596
17623
  const message = error instanceof Error ? error.message : String(error);
17597
17624
  return /unknown command|unknown method|not (yet )?(been )?implemented|not (yet )?supported|does not support/i.test(message);
17598
17625
  }
17626
+ var W3C_RETURN_KEY = "\uE007";
17599
17627
  function w3cKeyActions(text2) {
17600
17628
  const actions = [];
17601
17629
  for (const ch of text2) {
17602
- actions.push({ type: "keyDown", value: ch }, { type: "keyUp", value: ch });
17630
+ const value = ch === `
17631
+ ` || ch === "\r" ? W3C_RETURN_KEY : ch;
17632
+ actions.push({ type: "keyDown", value }, { type: "keyUp", value });
17603
17633
  }
17604
17634
  return [{ type: "key", id: "keyboard", actions }];
17605
17635
  }
@@ -17623,10 +17653,60 @@ async function typeText(browser, text2) {
17623
17653
  return "w3c";
17624
17654
  }
17625
17655
 
17656
+ // src/domains/devices/screenshot-retention.ts
17657
+ import { readdir as readdir2, stat as stat2, unlink } from "fs/promises";
17658
+ import { homedir as homedir6 } from "os";
17659
+ import { join as join7 } from "path";
17660
+ var SCREENSHOT_DIR = join7(homedir6(), ".yoqa", "runs", "screenshots");
17661
+ var SHOT_FILE_RE = /^shot_\d+_[0-9a-f-]+\.png$/i;
17662
+ var DEFAULT_RETENTION_DAYS = 7;
17663
+ var DAY_MS = 24 * 60 * 60 * 1000;
17664
+ function selectScreenshotsForPruning(entries, options) {
17665
+ if (options.maxAgeMs <= 0)
17666
+ return [];
17667
+ const cutoff = options.now - options.maxAgeMs;
17668
+ return entries.filter((entry) => SHOT_FILE_RE.test(entry.name) && entry.mtimeMs < cutoff).map((entry) => entry.name);
17669
+ }
17670
+ function resolveScreenshotRetentionMs(env = process.env) {
17671
+ const raw = env.YOQA_SCREENSHOT_RETENTION_DAYS;
17672
+ if (raw === undefined)
17673
+ return DEFAULT_RETENTION_DAYS * DAY_MS;
17674
+ const days = Number(raw);
17675
+ if (!Number.isFinite(days))
17676
+ return DEFAULT_RETENTION_DAYS * DAY_MS;
17677
+ return days * DAY_MS;
17678
+ }
17679
+ async function pruneOldRunScreenshots(options = {}) {
17680
+ const maxAgeMs = options.maxAgeMs ?? resolveScreenshotRetentionMs();
17681
+ if (maxAgeMs <= 0)
17682
+ return { removed: 0 };
17683
+ const dir = options.dir ?? SCREENSHOT_DIR;
17684
+ try {
17685
+ const names = (await readdir2(dir)).filter((name) => SHOT_FILE_RE.test(name));
17686
+ const entries = [];
17687
+ for (const name of names) {
17688
+ const info = await stat2(join7(dir, name));
17689
+ entries.push({ name, mtimeMs: info.mtimeMs });
17690
+ }
17691
+ let removed = 0;
17692
+ for (const name of selectScreenshotsForPruning(entries, {
17693
+ maxAgeMs,
17694
+ now: options.now ?? Date.now()
17695
+ })) {
17696
+ try {
17697
+ await unlink(join7(dir, name));
17698
+ removed += 1;
17699
+ } catch {}
17700
+ }
17701
+ return { removed };
17702
+ } catch {
17703
+ return { removed: 0 };
17704
+ }
17705
+ }
17706
+
17626
17707
  // src/domains/devices/session.ts
17627
- var YOQA_ROOT4 = join7(homedir6(), ".yoqa");
17708
+ var YOQA_ROOT4 = join8(homedir7(), ".yoqa");
17628
17709
  var DEFAULT_MJPEG_PORT = Number(process.env.YOQA_MJPEG_PORT ?? "9100");
17629
- var SCREENSHOT_DIR = join7(YOQA_ROOT4, "runs", "screenshots");
17630
17710
  var MJPEG_SETTINGS_BASE = {
17631
17711
  mjpegServerScreenshotQuality: 35,
17632
17712
  mjpegScalingFactor: 50
@@ -17744,7 +17824,7 @@ function looksLikePhysicalIosUdid(udid) {
17744
17824
  }
17745
17825
  var PHYSICAL_IOS_SESSION_TIMEOUT_MS = 60000;
17746
17826
  var SIMULATOR_WDA_SESSION_TIMEOUT_MS = 600000;
17747
- var SIMULATOR_WDA_DERIVED_DATA = join7(YOQA_ROOT4, "wda-sim");
17827
+ var SIMULATOR_WDA_DERIVED_DATA = join8(YOQA_ROOT4, "wda-sim");
17748
17828
  function iosSessionCreateTimeoutMs(deviceId) {
17749
17829
  return looksLikePhysicalIosUdid(deviceId) ? PHYSICAL_IOS_SESSION_TIMEOUT_MS : SIMULATOR_WDA_SESSION_TIMEOUT_MS;
17750
17830
  }
@@ -18089,7 +18169,7 @@ async function createDeviceSession(options) {
18089
18169
  const screenshot = async () => {
18090
18170
  await mkdir3(SCREENSHOT_DIR, { recursive: true });
18091
18171
  const frame = await captureFrame();
18092
- const path = join7(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
18172
+ const path = join8(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
18093
18173
  await Bun.write(path, Uint8Array.from(Buffer.from(frame.base64, "base64")));
18094
18174
  return { path, base64: frame.base64 };
18095
18175
  };
@@ -24284,6 +24364,7 @@ var catalogAppSchema = exports_external.object({
24284
24364
  name: exports_external.string().min(1),
24285
24365
  prefix: exports_external.string().min(1),
24286
24366
  context: exports_external.string(),
24367
+ knowledge: exports_external.string(),
24287
24368
  iosBundleId: exports_external.string(),
24288
24369
  iosAppStoreId: exports_external.string(),
24289
24370
  androidApplicationId: exports_external.string(),
@@ -24299,6 +24380,7 @@ var updateAppRequestSchema = exports_external.object({
24299
24380
  name: exports_external.string().min(1).optional(),
24300
24381
  prefix: exports_external.string().min(1).optional(),
24301
24382
  context: exports_external.string().optional(),
24383
+ knowledge: exports_external.string().optional(),
24302
24384
  iosBundleId: exports_external.string().optional(),
24303
24385
  iosAppStoreId: exports_external.string().optional(),
24304
24386
  androidApplicationId: exports_external.string().optional(),
@@ -24612,7 +24694,8 @@ var probeProviderResponseSchema = exports_external.object({
24612
24694
  var providerModelSchema = exports_external.object({
24613
24695
  id: exports_external.string().min(1),
24614
24696
  name: exports_external.string().min(1),
24615
- provider: exports_external.string().min(1).optional()
24697
+ provider: exports_external.string().min(1).optional(),
24698
+ vision: exports_external.boolean().optional()
24616
24699
  });
24617
24700
  var listProviderModelsResponseSchema = exports_external.object({
24618
24701
  models: exports_external.array(providerModelSchema),
@@ -24641,6 +24724,7 @@ var runExecutionModeSchema = exports_external.union([
24641
24724
  exports_external.literal("script"),
24642
24725
  exports_external.literal("agent")
24643
24726
  ]);
24727
+ var runScreenModeSchema = exports_external.union([exports_external.literal("vision"), exports_external.literal("tree")]);
24644
24728
  var createRunRequestSchema = exports_external.object({
24645
24729
  appId: exports_external.string().min(1),
24646
24730
  caseIds: exports_external.array(exports_external.string().min(1)).min(1),
@@ -24648,7 +24732,17 @@ var createRunRequestSchema = exports_external.object({
24648
24732
  platform: devicePlatformSchema,
24649
24733
  buildId: exports_external.string().min(1).optional(),
24650
24734
  buildPath: exports_external.string().min(1).optional(),
24651
- executionMode: runExecutionModeSchema.optional()
24735
+ executionMode: runExecutionModeSchema.optional(),
24736
+ screenMode: runScreenModeSchema.optional()
24737
+ });
24738
+ var stepPhasesSchema = exports_external.object({
24739
+ captureMs: exports_external.number().nonnegative(),
24740
+ screenMs: exports_external.number().nonnegative(),
24741
+ prepareMs: exports_external.number().nonnegative(),
24742
+ decideMs: exports_external.number().nonnegative(),
24743
+ actionMs: exports_external.number().nonnegative(),
24744
+ settleMs: exports_external.number().nonnegative(),
24745
+ decideRetries: exports_external.number().int().nonnegative()
24652
24746
  });
24653
24747
  var runStepSchema = exports_external.object({
24654
24748
  id: exports_external.string().min(1),
@@ -24658,6 +24752,7 @@ var runStepSchema = exports_external.object({
24658
24752
  screenshotUri: exports_external.string().nullable(),
24659
24753
  ok: exports_external.boolean(),
24660
24754
  latencyMs: exports_external.number().nonnegative(),
24755
+ phases: stepPhasesSchema.nullable().optional(),
24661
24756
  detail: exports_external.string().nullable(),
24662
24757
  command: exports_external.string().nullable().optional(),
24663
24758
  createdAt: exports_external.number().int().nonnegative()
@@ -24682,6 +24777,7 @@ var runSchema = exports_external.object({
24682
24777
  buildId: exports_external.string().nullable(),
24683
24778
  status: runStatusSchema,
24684
24779
  executionMode: runExecutionModeSchema,
24780
+ screenMode: runScreenModeSchema.optional(),
24685
24781
  error: exports_external.string().nullable(),
24686
24782
  createdAt: exports_external.number().int().nonnegative(),
24687
24783
  startedAt: exports_external.number().int().nonnegative().nullable(),
@@ -24768,7 +24864,16 @@ var actionRequestSchema = exports_external.object({
24768
24864
  appId: exports_external.string().optional(),
24769
24865
  url: exports_external.string().optional(),
24770
24866
  alertAction: exports_external.union([exports_external.literal("accept"), exports_external.literal("dismiss")]).optional(),
24771
- seconds: exports_external.number().optional()
24867
+ seconds: exports_external.number().optional(),
24868
+ screenshot: exports_external.boolean().optional(),
24869
+ settleMs: exports_external.number().int().min(0).max(1e4).optional()
24870
+ });
24871
+ var actionResultScreenshotSchema = exports_external.object({
24872
+ path: exports_external.string().min(1),
24873
+ annotatedPath: exports_external.string().min(1).optional(),
24874
+ settled: exports_external.boolean(),
24875
+ waitedMs: exports_external.number(),
24876
+ changed: exports_external.boolean().nullable()
24772
24877
  });
24773
24878
  var actionResponseSchema = exports_external.object({
24774
24879
  ok: exports_external.literal(true),
@@ -24776,7 +24881,8 @@ var actionResponseSchema = exports_external.object({
24776
24881
  resolved: exports_external.object({
24777
24882
  x: exports_external.number().optional(),
24778
24883
  y: exports_external.number().optional()
24779
- }).optional()
24884
+ }).optional(),
24885
+ screenshot: actionResultScreenshotSchema.optional()
24780
24886
  });
24781
24887
  var yoqaStatusResponseSchema = exports_external.object({
24782
24888
  runner: exports_external.object({
@@ -25350,9 +25456,9 @@ function normalizeVisionJson(value) {
25350
25456
 
25351
25457
  // src/domains/providers/vision-model.ts
25352
25458
  import { mkdtemp as mkdtemp3, readFile as readFile2, rm as rm3, writeFile as writeFile3 } from "fs/promises";
25353
- import { homedir as homedir7 } from "os";
25459
+ import { homedir as homedir8 } from "os";
25354
25460
  import { tmpdir as tmpdir3 } from "os";
25355
- import { join as join8 } from "path";
25461
+ import { join as join9 } from "path";
25356
25462
 
25357
25463
  // ../../node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider/dist/index.js
25358
25464
  var marker = "vercel.ai.error";
@@ -48024,9 +48130,9 @@ async function prepareVisionImage(pngBase64) {
48024
48130
  if (process.platform !== "darwin") {
48025
48131
  return { base64: pngBase64, mediaType: "image/png" };
48026
48132
  }
48027
- const dir = await mkdtemp3(join8(tmpdir3(), "yoqa-vision-"));
48028
- const inPath = join8(dir, "shot.png");
48029
- const outPath = join8(dir, "shot.jpg");
48133
+ const dir = await mkdtemp3(join9(tmpdir3(), "yoqa-vision-"));
48134
+ const inPath = join9(dir, "shot.png");
48135
+ const outPath = join9(dir, "shot.jpg");
48030
48136
  try {
48031
48137
  await writeFile3(inPath, new Uint8Array(bytes));
48032
48138
  const proc = Bun.spawn([
@@ -48080,6 +48186,9 @@ function formatProviderHttpError(label, status, body) {
48080
48186
  if (label === "OpenCode" && status >= 500 && (body.includes("Internal server error") || body.includes('"type":"error"'))) {
48081
48187
  return `OpenCode Zen returned an internal error for this vision request. Many free models reject screenshots. In Settings \u2192 Provider, set the default model to ${EXAMPLE_OPENCODE_VISION_MODEL} (or another vision-capable model).`;
48082
48188
  }
48189
+ if (label === "Groq" && status === 400 && (body.includes("invalid JSON schema for response_format") || body.includes("required") && body.includes("every key in properties"))) {
48190
+ return "Groq rejected strict JSON schema mode for this model (response_format requires every key in properties). The runner already compensates with prompt JSON \u2014 retry the run; if it persists, pick a different Groq model in Settings \u2192 Provider.";
48191
+ }
48083
48192
  return `${label} request failed (${status}): ${body.slice(0, 400)}`;
48084
48193
  }
48085
48194
  function isJsonRepairableError(error40) {
@@ -48134,7 +48243,7 @@ async function completeWithAiSdk(input) {
48134
48243
  model: input.model,
48135
48244
  schema: input.schema,
48136
48245
  system: input.system,
48137
- maxOutputTokens: VISION_MAX_TOKENS,
48246
+ maxOutputTokens: input.maxOutputTokens ?? VISION_MAX_TOKENS,
48138
48247
  experimental_repairText: async ({ text: text3 }) => salvageAgentJsonText(text3),
48139
48248
  messages: [
48140
48249
  {
@@ -48172,6 +48281,7 @@ async function completeWithAiSdk(input) {
48172
48281
  if (!(error40 instanceof AgentProviderError) || !isJsonRepairableError(error40)) {
48173
48282
  throw error40;
48174
48283
  }
48284
+ input.onDecideRetry?.();
48175
48285
  return run(`${input.prompt}
48176
48286
 
48177
48287
  ${JSON_REPAIR_PROMPT}`);
@@ -48181,7 +48291,7 @@ function createSdkVisionPort(opts) {
48181
48291
  return {
48182
48292
  async completeObject(input) {
48183
48293
  const modelId = input.auth.defaultModel?.trim() || opts.defaultModel;
48184
- const image = await prepareVisionImage(input.imageBase64);
48294
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
48185
48295
  const model = await opts.createModel(input.auth, modelId);
48186
48296
  return completeWithAiSdk({
48187
48297
  label: opts.label,
@@ -48189,7 +48299,9 @@ function createSdkVisionPort(opts) {
48189
48299
  schema: input.schema,
48190
48300
  system: input.system,
48191
48301
  prompt: input.prompt,
48192
- image
48302
+ image,
48303
+ onDecideRetry: input.onDecideRetry,
48304
+ maxOutputTokens: opts.maxOutputTokens
48193
48305
  });
48194
48306
  }
48195
48307
  };
@@ -48201,8 +48313,8 @@ function openCodeAuthPaths() {
48201
48313
  const paths = [];
48202
48314
  const xdg = process.env.XDG_DATA_HOME?.trim();
48203
48315
  if (xdg)
48204
- paths.push(join8(xdg, "opencode", "auth.json"));
48205
- paths.push(join8(homedir7(), ".local", "share", "opencode", "auth.json"));
48316
+ paths.push(join9(xdg, "opencode", "auth.json"));
48317
+ paths.push(join9(homedir8(), ".local", "share", "opencode", "auth.json"));
48206
48318
  return paths;
48207
48319
  }
48208
48320
  function apiKeyFromOpenCodeAuthEntry(value) {
@@ -48295,6 +48407,64 @@ function resolveGoogleKey(auth) {
48295
48407
  function resolveVertexApiKey(auth) {
48296
48408
  return auth.apiKey?.trim() || auth.env.GOOGLE_VERTEX_API_KEY?.trim() || process.env.GOOGLE_VERTEX_API_KEY?.trim() || null;
48297
48409
  }
48410
+ function isSparseResponseFormatBody(parsedBody) {
48411
+ const format = parsedBody.response_format;
48412
+ if (!format || typeof format !== "object" || Array.isArray(format))
48413
+ return false;
48414
+ const jsonSchema3 = format.json_schema;
48415
+ if (!jsonSchema3 || typeof jsonSchema3 !== "object" || Array.isArray(jsonSchema3)) {
48416
+ return false;
48417
+ }
48418
+ const schema = jsonSchema3.schema;
48419
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
48420
+ return false;
48421
+ const properties = schema.properties;
48422
+ if (!properties || typeof properties !== "object" || Array.isArray(properties)) {
48423
+ return false;
48424
+ }
48425
+ const keys = Object.keys(properties);
48426
+ if (keys.length === 0)
48427
+ return false;
48428
+ const required2 = schema.required;
48429
+ if (!Array.isArray(required2))
48430
+ return true;
48431
+ const requiredSet = new Set(required2.filter((entry) => typeof entry === "string"));
48432
+ return keys.some((key) => !requiredSet.has(key));
48433
+ }
48434
+ var GROQ_REASONING_MODEL_RE = /qwen3/i;
48435
+ function rewriteGroqRequestBody(rawBody) {
48436
+ try {
48437
+ const parsed = JSON.parse(rawBody);
48438
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
48439
+ return null;
48440
+ const parsedBody = { ...parsed };
48441
+ let mutated = false;
48442
+ if (typeof parsedBody.model === "string" && GROQ_REASONING_MODEL_RE.test(parsedBody.model)) {
48443
+ parsedBody.reasoning_effort = "none";
48444
+ mutated = true;
48445
+ }
48446
+ if (isSparseResponseFormatBody(parsedBody)) {
48447
+ const { response_format: _responseFormat, ...withoutFormat } = parsedBody;
48448
+ return JSON.stringify(withoutFormat);
48449
+ }
48450
+ return mutated ? JSON.stringify(parsedBody) : null;
48451
+ } catch {
48452
+ return null;
48453
+ }
48454
+ }
48455
+ function withGroqRequestHooks(opts) {
48456
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
48457
+ return async (input, init) => {
48458
+ const headers = new Headers(init?.headers);
48459
+ const requestInit = { ...init, headers };
48460
+ if (typeof init?.body === "string") {
48461
+ const rewrittenBody = rewriteGroqRequestBody(init.body);
48462
+ if (rewrittenBody !== null)
48463
+ requestInit.body = rewrittenBody;
48464
+ }
48465
+ return fetchImpl(input, requestInit);
48466
+ };
48467
+ }
48298
48468
  function withOpenCodeRequestHooks(opts) {
48299
48469
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
48300
48470
  return async (input, init) => {
@@ -57355,8 +57525,8 @@ var anthropic = createAnthropic();
57355
57525
  // src/domains/providers/drivers/probe.ts
57356
57526
  import { constants } from "fs";
57357
57527
  import { access } from "fs/promises";
57358
- import { homedir as homedir8 } from "os";
57359
- import { isAbsolute, join as join9 } from "path";
57528
+ import { homedir as homedir9 } from "os";
57529
+ import { isAbsolute, join as join10 } from "path";
57360
57530
  async function pathExists3(path) {
57361
57531
  try {
57362
57532
  await access(path, constants.F_OK);
@@ -57381,13 +57551,13 @@ async function which2(binary) {
57381
57551
  `)[0]?.trim();
57382
57552
  return resolved || null;
57383
57553
  }
57384
- function knownHomeBinCandidates(binaryName, home = homedir8()) {
57554
+ function knownHomeBinCandidates(binaryName, home = homedir9()) {
57385
57555
  return [
57386
- join9(home, ".opencode", "bin", binaryName),
57387
- join9(home, ".grok", "bin", binaryName),
57388
- join9(home, ".antigravity", "antigravity", "bin", binaryName),
57389
- join9(home, ".local", "bin", binaryName),
57390
- join9(home, ".bun", "bin", binaryName),
57556
+ join10(home, ".opencode", "bin", binaryName),
57557
+ join10(home, ".grok", "bin", binaryName),
57558
+ join10(home, ".antigravity", "antigravity", "bin", binaryName),
57559
+ join10(home, ".local", "bin", binaryName),
57560
+ join10(home, ".bun", "bin", binaryName),
57391
57561
  `/opt/homebrew/bin/${binaryName}`,
57392
57562
  `/usr/local/bin/${binaryName}`
57393
57563
  ];
@@ -57620,7 +57790,7 @@ var anthropicDriver = {
57620
57790
  // src/domains/providers/drivers/antigravity-vision.ts
57621
57791
  import { mkdtemp as mkdtemp4, rm as rm4, writeFile as writeFile4 } from "fs/promises";
57622
57792
  import { tmpdir as tmpdir4 } from "os";
57623
- import { join as join10 } from "path";
57793
+ import { join as join11 } from "path";
57624
57794
 
57625
57795
  // ../../node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider/dist/index.js
57626
57796
  var marker29 = "vercel.ai.error";
@@ -67093,11 +67263,11 @@ async function completeWithAgyCli(input, repairHint) {
67093
67263
  if (!resolved.path) {
67094
67264
  throw new AgentProviderError(resolved.detail || "Antigravity CLI (`agy`) not found. Install it or paste a Google AI Studio API key.");
67095
67265
  }
67096
- const image = await prepareVisionImage(input.imageBase64);
67266
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
67097
67267
  const model = input.auth.defaultModel?.trim() || ANTIGRAVITY_DEFAULT_VISION_MODEL;
67098
67268
  const ext = image.mediaType === "image/jpeg" ? "jpg" : "png";
67099
- const dir = await mkdtemp4(join10(tmpdir4(), "yoqa-agy-"));
67100
- const shotPath = join10(dir, `shot.${ext}`);
67269
+ const dir = await mkdtemp4(join11(tmpdir4(), "yoqa-agy-"));
67270
+ const shotPath = join11(dir, `shot.${ext}`);
67101
67271
  try {
67102
67272
  await writeFile4(shotPath, new Uint8Array(Buffer.from(image.base64, "base64")));
67103
67273
  const userText = [
@@ -67148,7 +67318,7 @@ var antigravityVision = {
67148
67318
  if (apiKey) {
67149
67319
  const requested = input.auth.defaultModel?.trim() || ANTIGRAVITY_DEFAULT_VISION_MODEL;
67150
67320
  const googleModelId = requested.startsWith("gemini-1.") || requested.startsWith("gemini-2.") ? requested : "gemini-2.5-flash";
67151
- const image = await prepareVisionImage(input.imageBase64);
67321
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
67152
67322
  const provider = createGoogle({ apiKey });
67153
67323
  return completeWithAiSdk({
67154
67324
  label: "Antigravity",
@@ -67156,7 +67326,8 @@ var antigravityVision = {
67156
67326
  schema: input.schema,
67157
67327
  system: input.system,
67158
67328
  prompt: input.prompt,
67159
- image
67329
+ image,
67330
+ onDecideRetry: input.onDecideRetry
67160
67331
  });
67161
67332
  }
67162
67333
  try {
@@ -67165,6 +67336,7 @@ var antigravityVision = {
67165
67336
  if (!(error40 instanceof AgentProviderError) || !isJsonRepairableError(error40)) {
67166
67337
  throw error40;
67167
67338
  }
67339
+ input.onDecideRetry?.();
67168
67340
  return completeWithAgyCli(input, JSON_REPAIR_PROMPT2);
67169
67341
  }
67170
67342
  }
@@ -75611,7 +75783,7 @@ ${result.stderr}`.toLowerCase();
75611
75783
  // src/domains/providers/drivers/cursor-vision.ts
75612
75784
  import { mkdtemp as mkdtemp5, rm as rm5, writeFile as writeFile5 } from "fs/promises";
75613
75785
  import { tmpdir as tmpdir5 } from "os";
75614
- import { join as join11 } from "path";
75786
+ import { join as join12 } from "path";
75615
75787
  var CURSOR_DEFAULT_VISION_MODEL = "auto";
75616
75788
  var JSON_REPAIR_PROMPT3 = "Your previous reply was not valid JSON for this task. Reply again with ONLY one strict JSON object using double quotes for every key and string (no single quotes, no markdown, no prose).";
75617
75789
  function resolveCursorApiKey(auth) {
@@ -75624,8 +75796,8 @@ async function invokeCursorPrint(input) {
75624
75796
  }
75625
75797
  const model = input.auth.defaultModel?.trim() || CURSOR_DEFAULT_VISION_MODEL;
75626
75798
  const ext = input.mediaType === "image/jpeg" ? "jpg" : "png";
75627
- const dir = await mkdtemp5(join11(tmpdir5(), "yoqa-cursor-"));
75628
- const shotPath = join11(dir, `shot.${ext}`);
75799
+ const dir = await mkdtemp5(join12(tmpdir5(), "yoqa-cursor-"));
75800
+ const shotPath = join12(dir, `shot.${ext}`);
75629
75801
  const apiKey = resolveCursorApiKey(input.auth);
75630
75802
  try {
75631
75803
  await writeFile5(shotPath, new Uint8Array(Buffer.from(input.imageBase64, "base64")));
@@ -75668,7 +75840,7 @@ ${result.stderr}`.trim();
75668
75840
  }
75669
75841
  }
75670
75842
  async function completeOnce(input, repairHint) {
75671
- const image = await prepareVisionImage(input.imageBase64);
75843
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
75672
75844
  const ext = image.mediaType === "image/jpeg" ? "jpg" : "png";
75673
75845
  const shotName = `shot.${ext}`;
75674
75846
  const userText = [
@@ -75707,6 +75879,7 @@ var cursorVision = {
75707
75879
  if (!(error40 instanceof AgentProviderError) || !isJsonRepairableError(error40)) {
75708
75880
  throw error40;
75709
75881
  }
75882
+ input.onDecideRetry?.();
75710
75883
  return completeOnce(input, JSON_REPAIR_PROMPT3);
75711
75884
  }
75712
75885
  }
@@ -95598,6 +95771,18 @@ var groq = createGroq();
95598
95771
 
95599
95772
  // src/domains/providers/drivers/groq.ts
95600
95773
  var DEFAULT_BASE2 = "https://api.groq.com/openai/v1";
95774
+ var GROQ_VISION_MODEL_RE = /llama-4-(scout|maverick)/i;
95775
+ var GROQ_TEXT_ONLY_MODEL_RE = /^(qwen|deepseek|llama-3|gemma|mixtral|whisper|openai\/gpt-oss|compound-beta)/i;
95776
+ function groqModelVision(modelId) {
95777
+ const id = modelId.trim();
95778
+ if (!id)
95779
+ return;
95780
+ if (GROQ_VISION_MODEL_RE.test(id))
95781
+ return true;
95782
+ if (GROQ_TEXT_ONLY_MODEL_RE.test(id))
95783
+ return false;
95784
+ return;
95785
+ }
95601
95786
  var groqDriver = {
95602
95787
  kind: "groq",
95603
95788
  label: "Groq",
@@ -95610,13 +95795,14 @@ var groqDriver = {
95610
95795
  vision: createSdkVisionPort({
95611
95796
  label: "Groq",
95612
95797
  defaultModel: "meta-llama/llama-4-scout-17b-16e-instruct",
95798
+ maxOutputTokens: 2048,
95613
95799
  createModel: (auth, modelId) => {
95614
95800
  const apiKey = resolveGroqKey(auth);
95615
95801
  if (!apiKey) {
95616
95802
  throw new AgentProviderError("Groq provider has no API key");
95617
95803
  }
95618
95804
  const baseURL = auth.baseUrl?.trim().replace(/\/$/, "") || DEFAULT_BASE2;
95619
- return createGroq({ apiKey, baseURL })(modelId);
95805
+ return createGroq({ apiKey, baseURL, fetch: withGroqRequestHooks({}) })(modelId);
95620
95806
  }
95621
95807
  }),
95622
95808
  async probe() {
@@ -95657,7 +95843,10 @@ var groqDriver = {
95657
95843
  label: "Groq"
95658
95844
  });
95659
95845
  return {
95660
- models: (result.models ?? []).map((id) => ({ id, name: id })),
95846
+ models: (result.models ?? []).map((id) => {
95847
+ const vision = groqModelVision(id);
95848
+ return { id, name: id, ...vision === undefined ? {} : { vision } };
95849
+ }),
95661
95850
  message: result.ok ? `${result.models?.length ?? 0} models available` : result.message
95662
95851
  };
95663
95852
  }
@@ -95733,8 +95922,8 @@ var openaiDriver = {
95733
95922
  };
95734
95923
 
95735
95924
  // src/domains/providers/drivers/opencode-config.ts
95736
- import { homedir as homedir9 } from "os";
95737
- import { join as join12 } from "path";
95925
+ import { homedir as homedir10 } from "os";
95926
+ import { join as join13 } from "path";
95738
95927
  var OPENAI_COMPATIBLE_PACKAGES = new Set([
95739
95928
  "@ai-sdk/openai-compatible",
95740
95929
  "@ai-sdk/openai",
@@ -95762,8 +95951,8 @@ function openCodeConfigPaths() {
95762
95951
  const paths = [];
95763
95952
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
95764
95953
  if (xdg)
95765
- paths.push(join12(xdg, "opencode", "opencode.json"));
95766
- paths.push(join12(homedir9(), ".config", "opencode", "opencode.json"));
95954
+ paths.push(join13(xdg, "opencode", "opencode.json"));
95955
+ paths.push(join13(homedir10(), ".config", "opencode", "opencode.json"));
95767
95956
  return paths;
95768
95957
  }
95769
95958
  function parseOpenCodeCompatibleProvider(config2, providerId) {
@@ -96221,6 +96410,348 @@ async function completeVision(auth, input) {
96221
96410
  return resolveVision(auth).completeObject({ ...input, auth });
96222
96411
  }
96223
96412
 
96413
+ // src/domains/runs/coord-grid.ts
96414
+ import { crc32, deflateSync, inflateSync } from "zlib";
96415
+ var PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
96416
+ function pngChunk(type, data) {
96417
+ const typeAndData = Buffer.concat([Buffer.from(type), data]);
96418
+ const length = Buffer.alloc(4);
96419
+ length.writeUInt32BE(data.length);
96420
+ const crc = Buffer.alloc(4);
96421
+ crc.writeUInt32BE(crc32(typeAndData) >>> 0);
96422
+ return Buffer.concat([length, typeAndData, crc]);
96423
+ }
96424
+ function encodeRgbaPng(image) {
96425
+ const { width, height, rgba } = image;
96426
+ const stride = width * 4;
96427
+ const raw2 = Buffer.alloc(height * (1 + stride));
96428
+ for (let y = 0;y < height; y++) {
96429
+ const row = y * (1 + stride);
96430
+ raw2[row] = 0;
96431
+ Buffer.from(rgba.buffer, rgba.byteOffset + y * stride, stride).copy(raw2, row + 1);
96432
+ }
96433
+ const ihdr = Buffer.alloc(13);
96434
+ ihdr.writeUInt32BE(width, 0);
96435
+ ihdr.writeUInt32BE(height, 4);
96436
+ ihdr[8] = 8;
96437
+ ihdr[9] = 6;
96438
+ return Buffer.concat([
96439
+ PNG_SIGNATURE,
96440
+ pngChunk("IHDR", ihdr),
96441
+ pngChunk("IDAT", deflateSync(raw2)),
96442
+ pngChunk("IEND", Buffer.alloc(0))
96443
+ ]);
96444
+ }
96445
+ function paeth(left, up, upLeft) {
96446
+ const estimate = left + up - upLeft;
96447
+ const leftDist = Math.abs(estimate - left);
96448
+ const upDist = Math.abs(estimate - up);
96449
+ const upLeftDist = Math.abs(estimate - upLeft);
96450
+ if (leftDist <= upDist && leftDist <= upLeftDist)
96451
+ return left;
96452
+ if (upDist <= upLeftDist)
96453
+ return up;
96454
+ return upLeft;
96455
+ }
96456
+ function unfilter(raw2, height, stride, bpp) {
96457
+ const out = Buffer.alloc(height * stride);
96458
+ for (let y = 0;y < height; y++) {
96459
+ const filter8 = raw2[y * (1 + stride)];
96460
+ if (filter8 === undefined || filter8 > 4)
96461
+ return null;
96462
+ const src = y * (1 + stride) + 1;
96463
+ const dst = y * stride;
96464
+ for (let x = 0;x < stride; x++) {
96465
+ const value = raw2[src + x] ?? 0;
96466
+ const left = x >= bpp ? out[dst + x - bpp] ?? 0 : 0;
96467
+ const up = y > 0 ? out[dst - stride + x] ?? 0 : 0;
96468
+ const upLeft = y > 0 && x >= bpp ? out[dst - stride + x - bpp] ?? 0 : 0;
96469
+ let recon = value;
96470
+ if (filter8 === 1)
96471
+ recon = value + left;
96472
+ else if (filter8 === 2)
96473
+ recon = value + up;
96474
+ else if (filter8 === 3)
96475
+ recon = value + Math.floor((left + up) / 2);
96476
+ else if (filter8 === 4)
96477
+ recon = value + paeth(left, up, upLeft);
96478
+ out[dst + x] = recon & 255;
96479
+ }
96480
+ }
96481
+ return out;
96482
+ }
96483
+ function decodePng(bytes) {
96484
+ if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE))
96485
+ return null;
96486
+ let width = 0;
96487
+ let height = 0;
96488
+ let colorType = -1;
96489
+ const idat = [];
96490
+ let offset = 8;
96491
+ while (offset + 8 <= bytes.length) {
96492
+ const length = bytes.readUInt32BE(offset);
96493
+ const type = bytes.toString("ascii", offset + 4, offset + 8);
96494
+ const start = offset + 8;
96495
+ const end = start + length;
96496
+ if (end + 4 > bytes.length)
96497
+ return null;
96498
+ const data = bytes.subarray(start, end);
96499
+ if (type === "IHDR") {
96500
+ if (data.length < 13)
96501
+ return null;
96502
+ width = data.readUInt32BE(0);
96503
+ height = data.readUInt32BE(4);
96504
+ if (data[8] !== 8 || data[12] !== 0)
96505
+ return null;
96506
+ colorType = data[9] ?? -1;
96507
+ } else if (type === "IDAT") {
96508
+ idat.push(Buffer.from(data));
96509
+ } else if (type === "IEND") {
96510
+ break;
96511
+ }
96512
+ offset = end + 4;
96513
+ }
96514
+ if (width < 1 || height < 1 || width > 8192 || height > 8192)
96515
+ return null;
96516
+ const bpp = colorType === 6 ? 4 : colorType === 2 ? 3 : 0;
96517
+ if (bpp === 0)
96518
+ return null;
96519
+ let inflated;
96520
+ try {
96521
+ inflated = inflateSync(Buffer.concat(idat));
96522
+ } catch {
96523
+ return null;
96524
+ }
96525
+ const stride = width * bpp;
96526
+ if (inflated.length < height * (1 + stride))
96527
+ return null;
96528
+ const raw2 = unfilter(inflated, height, stride, bpp);
96529
+ if (!raw2)
96530
+ return null;
96531
+ const rgba = new Uint8Array(width * height * 4);
96532
+ if (bpp === 4) {
96533
+ rgba.set(raw2.subarray(0, rgba.length));
96534
+ } else {
96535
+ for (let i = 0;i < width * height; i++) {
96536
+ rgba[i * 4] = raw2[i * 3] ?? 0;
96537
+ rgba[i * 4 + 1] = raw2[i * 3 + 1] ?? 0;
96538
+ rgba[i * 4 + 2] = raw2[i * 3 + 2] ?? 0;
96539
+ rgba[i * 4 + 3] = 255;
96540
+ }
96541
+ }
96542
+ return { width, height, rgba };
96543
+ }
96544
+ function paintMagenta(rgba, index) {
96545
+ rgba[index] = 255;
96546
+ rgba[index + 1] = 0;
96547
+ rgba[index + 2] = 255;
96548
+ rgba[index + 3] = 255;
96549
+ }
96550
+ var GLYPHS = {
96551
+ "0": ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
96552
+ "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
96553
+ "2": ["01110", "10001", "00001", "00110", "01000", "10000", "11111"],
96554
+ "3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
96555
+ "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
96556
+ "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
96557
+ "6": ["01110", "10000", "11110", "10001", "10001", "10001", "01110"],
96558
+ "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
96559
+ "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
96560
+ "9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
96561
+ "-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"]
96562
+ };
96563
+ var GRID_SLOT = [5, 15, 25, 35, 45, 55, 65, 75, 85, 95];
96564
+ function clampCell(value, max2) {
96565
+ if (!Number.isFinite(value))
96566
+ return 0;
96567
+ return Math.min(max2, Math.max(0, Math.round(value)));
96568
+ }
96569
+ function pointFromGridCell(cell) {
96570
+ const slot = (value) => {
96571
+ if (value == null || !Number.isFinite(value))
96572
+ return 50;
96573
+ const rounded = Math.round(value);
96574
+ if (rounded < 0 || rounded > 9)
96575
+ return 50;
96576
+ return GRID_SLOT[rounded] ?? 50;
96577
+ };
96578
+ const qx = slot(cell.qx);
96579
+ const qy = slot(cell.qy);
96580
+ return {
96581
+ x: clampCell(cell.col, 9) * 100 + qx,
96582
+ y: clampCell(cell.row, 9) * 100 + qy
96583
+ };
96584
+ }
96585
+ function fillRect(rgba, width, height, x, y, w, h, color) {
96586
+ const xEnd = Math.min(width, x + w);
96587
+ const yEnd = Math.min(height, y + h);
96588
+ for (let py = Math.max(0, y);py < yEnd; py++) {
96589
+ for (let px = Math.max(0, x);px < xEnd; px++) {
96590
+ const index = (py * width + px) * 4;
96591
+ rgba[index] = color[0];
96592
+ rgba[index + 1] = color[1];
96593
+ rgba[index + 2] = color[2];
96594
+ rgba[index + 3] = 255;
96595
+ }
96596
+ }
96597
+ }
96598
+ function paintGlyph(rgba, width, height, glyph, originX, originY, scale) {
96599
+ for (let row = 0;row < glyph.length; row++) {
96600
+ const bits = glyph[row] ?? "";
96601
+ for (let col = 0;col < bits.length; col++) {
96602
+ if (bits[col] !== "1")
96603
+ continue;
96604
+ for (let sy = 0;sy < scale; sy++) {
96605
+ for (let sx = 0;sx < scale; sx++) {
96606
+ const px = originX + col * scale + sx;
96607
+ const py = originY + row * scale + sy;
96608
+ if (px < 0 || py < 0 || px >= width || py >= height)
96609
+ continue;
96610
+ const index = (py * width + px) * 4;
96611
+ rgba[index] = 255;
96612
+ rgba[index + 1] = 255;
96613
+ rgba[index + 2] = 255;
96614
+ rgba[index + 3] = 255;
96615
+ }
96616
+ }
96617
+ }
96618
+ }
96619
+ }
96620
+ function paintCellLabels(image) {
96621
+ const { width, height, rgba } = image;
96622
+ const stroke = Math.max(2, Math.round(Math.min(width, height) / 200));
96623
+ for (let col = 0;col < 10; col++) {
96624
+ for (let row = 0;row < 10; row++) {
96625
+ const x0 = Math.round(col / 10 * (width - 1));
96626
+ const y0 = Math.round(row / 10 * (height - 1));
96627
+ const x1 = Math.round((col + 1) / 10 * (width - 1));
96628
+ const y1 = Math.round((row + 1) / 10 * (height - 1));
96629
+ const scale = Math.floor(Math.min(x1 - x0, y1 - y0) / 24);
96630
+ if (scale < 1)
96631
+ continue;
96632
+ const text3 = `${col}-${row}`;
96633
+ const textW = text3.length * 5 + (text3.length - 1);
96634
+ const boxW = (textW + 2) * scale;
96635
+ const boxH = 9 * scale;
96636
+ if (x0 + stroke + boxW >= x1 || y0 + stroke + boxH >= y1)
96637
+ continue;
96638
+ const originX = x0 + stroke;
96639
+ const originY = y0 + stroke;
96640
+ fillRect(rgba, width, height, originX, originY, boxW, boxH, [0, 0, 0]);
96641
+ let cursor = originX + scale;
96642
+ for (const char of text3) {
96643
+ const glyph = GLYPHS[char];
96644
+ if (glyph)
96645
+ paintGlyph(rgba, width, height, glyph, cursor, originY + scale, scale);
96646
+ cursor += 6 * scale;
96647
+ }
96648
+ }
96649
+ }
96650
+ }
96651
+ function paintNormGrid(image) {
96652
+ const { width, height, rgba } = image;
96653
+ const stroke = Math.max(2, Math.round(Math.min(width, height) / 200));
96654
+ const half = Math.floor(stroke / 2);
96655
+ const paintColumn = (x) => {
96656
+ for (let dx = -half;dx < stroke - half; dx++) {
96657
+ const px = x + dx;
96658
+ if (px < 0 || px >= width)
96659
+ continue;
96660
+ for (let y = 0;y < height; y++)
96661
+ paintMagenta(rgba, (y * width + px) * 4);
96662
+ }
96663
+ };
96664
+ const paintRow = (y) => {
96665
+ for (let dy = -half;dy < stroke - half; dy++) {
96666
+ const py = y + dy;
96667
+ if (py < 0 || py >= height)
96668
+ continue;
96669
+ for (let x = 0;x < width; x++)
96670
+ paintMagenta(rgba, (py * width + x) * 4);
96671
+ }
96672
+ };
96673
+ for (let step = 1;step <= 9; step++) {
96674
+ paintColumn(Math.round(step / 10 * (width - 1)));
96675
+ paintRow(Math.round(step / 10 * (height - 1)));
96676
+ }
96677
+ }
96678
+ function overlayCoordGrid(pngBase64) {
96679
+ const bytes = Buffer.from(pngBase64, "base64");
96680
+ const image = decodePng(bytes);
96681
+ if (!image)
96682
+ return null;
96683
+ paintNormGrid(image);
96684
+ paintCellLabels(image);
96685
+ return encodeRgbaPng(image).toString("base64");
96686
+ }
96687
+ var MARK_RED = [255, 32, 32];
96688
+ var MARK_WHITE = [255, 255, 255];
96689
+ var MARK_GREEN = [0, 200, 80];
96690
+ function normToPx(norm, size) {
96691
+ const clamped = Math.min(1000, Math.max(0, Number.isFinite(norm) ? norm : 0));
96692
+ return Math.round(clamped / 1000 * (size - 1));
96693
+ }
96694
+ function paintRing(image, cx, cy, inner, outer, color) {
96695
+ const { width, height, rgba } = image;
96696
+ const outerSq = outer * outer;
96697
+ const innerSq = inner * inner;
96698
+ const reach = Math.ceil(outer);
96699
+ for (let py = Math.max(0, cy - reach);py <= Math.min(height - 1, cy + reach); py++) {
96700
+ for (let px = Math.max(0, cx - reach);px <= Math.min(width - 1, cx + reach); px++) {
96701
+ const distSq = (px - cx) ** 2 + (py - cy) ** 2;
96702
+ if (distSq > outerSq || distSq < innerSq)
96703
+ continue;
96704
+ const index = (py * width + px) * 4;
96705
+ rgba[index] = color[0];
96706
+ rgba[index + 1] = color[1];
96707
+ rgba[index + 2] = color[2];
96708
+ rgba[index + 3] = 255;
96709
+ }
96710
+ }
96711
+ }
96712
+ function paintThickLine(image, x0, y0, x1, y1, thickness, color) {
96713
+ const steps = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0), 1);
96714
+ const radius = Math.max(1, Math.floor(thickness / 2));
96715
+ for (let step = 0;step <= steps; step++) {
96716
+ const cx = Math.round(x0 + (x1 - x0) * step / steps);
96717
+ const cy = Math.round(y0 + (y1 - y0) * step / steps);
96718
+ paintRing(image, cx, cy, 0, radius, color);
96719
+ }
96720
+ }
96721
+ function paintLandingMark(image, cx, cy, unit) {
96722
+ const radius = unit * 4;
96723
+ paintRing(image, cx, cy, radius, radius + unit * 2, MARK_WHITE);
96724
+ paintRing(image, cx, cy, radius + unit * 0.5, radius + unit * 1.5, MARK_RED);
96725
+ paintRing(image, cx, cy, 0, unit * 1.5, MARK_RED);
96726
+ }
96727
+ function paintActionMarks(image, marks) {
96728
+ const unit = Math.max(2, Math.round(Math.min(image.width, image.height) / 200));
96729
+ for (const mark of marks) {
96730
+ const cx = normToPx(mark.x, image.width);
96731
+ const cy = normToPx(mark.y, image.height);
96732
+ if (mark.kind === "tap") {
96733
+ paintLandingMark(image, cx, cy, unit);
96734
+ continue;
96735
+ }
96736
+ const ex = normToPx(mark.x2, image.width);
96737
+ const ey = normToPx(mark.y2, image.height);
96738
+ paintThickLine(image, cx, cy, ex, ey, unit * 3, MARK_WHITE);
96739
+ paintThickLine(image, cx, cy, ex, ey, unit, MARK_RED);
96740
+ paintRing(image, cx, cy, 0, unit * 3, MARK_WHITE);
96741
+ paintRing(image, cx, cy, 0, unit * 2, MARK_GREEN);
96742
+ paintLandingMark(image, ex, ey, unit);
96743
+ }
96744
+ }
96745
+ function overlayActionMarks(pngBase64, marks) {
96746
+ if (marks.length === 0)
96747
+ return null;
96748
+ const image = decodePng(Buffer.from(pngBase64, "base64"));
96749
+ if (!image)
96750
+ return null;
96751
+ paintActionMarks(image, marks);
96752
+ return encodeRgbaPng(image).toString("base64");
96753
+ }
96754
+
96224
96755
  // src/domains/runs/agent.ts
96225
96756
  var swipeDirectionSchema = exports_external.union([
96226
96757
  exports_external.literal("up"),
@@ -96251,6 +96782,14 @@ var agentDecisionSchema = exports_external.object({
96251
96782
  y: exports_external.number().optional(),
96252
96783
  x2: exports_external.number().optional(),
96253
96784
  y2: exports_external.number().optional(),
96785
+ col: exports_external.number().optional(),
96786
+ row: exports_external.number().optional(),
96787
+ qx: exports_external.number().optional(),
96788
+ qy: exports_external.number().optional(),
96789
+ col2: exports_external.number().optional(),
96790
+ row2: exports_external.number().optional(),
96791
+ qx2: exports_external.number().optional(),
96792
+ qy2: exports_external.number().optional(),
96254
96793
  direction: swipeDirectionSchema.optional(),
96255
96794
  durationMs: exports_external.number().optional(),
96256
96795
  double: exports_external.boolean().optional(),
@@ -96313,12 +96852,174 @@ var MAX_SCREEN_SNAPSHOT_ROWS = 80;
96313
96852
  function padNorm(n) {
96314
96853
  return String(Math.round(n)).padStart(4, " ");
96315
96854
  }
96855
+ function isFullBleedElement(element) {
96856
+ return element.width >= 900 && element.height >= 900;
96857
+ }
96858
+ var GAME_APP_RE = /\b(this is a game|is a game|game app|mobile game|video game|unity|unreal|gameplay)\b/i;
96859
+ function isGameApp(appContext, appKnowledge) {
96860
+ return GAME_APP_RE.test(`${appContext}
96861
+ ${appKnowledge ?? ""}`);
96862
+ }
96863
+ var NO_TREE_SNAPSHOT = "(screenshot only \u2014 no accessibility tree is attached. Name the centre of the control on the screenshot.)";
96864
+ function isGameSurface(elements) {
96865
+ if (elements.length === 0 || !elements.some((element) => isFullBleedElement(element))) {
96866
+ return false;
96867
+ }
96868
+ return !elements.some((element) => {
96869
+ if (isFullBleedElement(element))
96870
+ return false;
96871
+ return element.label.trim().length > 0 || Boolean(element.id?.trim());
96872
+ });
96873
+ }
96874
+ function isCanvasScreen(elements) {
96875
+ const namedSmall = elements.filter((element) => {
96876
+ if (isFullBleedElement(element))
96877
+ return false;
96878
+ const named = element.label.trim().length > 0 || Boolean(element.id?.trim());
96879
+ return named && element.width <= 800 && element.height <= 400;
96880
+ });
96881
+ return namedSmall.length < 3;
96882
+ }
96883
+ function releaseCanvasPoint(decision, elements) {
96884
+ if (decision.x == null || decision.y == null || !decision.id)
96885
+ return decision;
96886
+ if (decision.type !== "tap" && decision.type !== "type" && decision.type !== "input") {
96887
+ return decision;
96888
+ }
96889
+ const match2 = elements.find((element) => element.id === decision.id);
96890
+ if (!match2 || !isFullBleedElement(match2))
96891
+ return decision;
96892
+ const { id: _fullBleedId, ...point } = decision;
96893
+ return point;
96894
+ }
96895
+ var SNAP_RADIUS = 100;
96896
+ var TAPPABLE_ROLE_RE = /button|link|cell|switch|checkbox|radio|tab|toggle|textfield|searchfield|segment|menuitem|stepper|slider|picker/i;
96897
+ function isTappableElement(element) {
96898
+ if (isFullBleedElement(element))
96899
+ return false;
96900
+ if (element.enabled === false || element.visible === false)
96901
+ return false;
96902
+ if (!element.label.trim() && !element.id?.trim())
96903
+ return false;
96904
+ return TAPPABLE_ROLE_RE.test(element.type);
96905
+ }
96906
+ function distanceToElement(element, x, y) {
96907
+ const dx = Math.max(element.x - x, 0, x - (element.x + element.width));
96908
+ const dy = Math.max(element.y - y, 0, y - (element.y + element.height));
96909
+ return Math.hypot(dx, dy);
96910
+ }
96911
+ function snapTapToElement(decision, elements) {
96912
+ const none = { decision, element: null };
96913
+ if (decision.type !== "tap" || decision.x == null || decision.y == null)
96914
+ return none;
96915
+ if (decision.id || decision.label || decision.description)
96916
+ return none;
96917
+ let best = null;
96918
+ for (const element2 of elements) {
96919
+ if (!isTappableElement(element2))
96920
+ continue;
96921
+ const distance = distanceToElement(element2, decision.x, decision.y);
96922
+ if (distance > SNAP_RADIUS)
96923
+ continue;
96924
+ const smaller = best != null && distance === best.distance && element2.width * element2.height < best.element.width * best.element.height;
96925
+ if (best == null || distance < best.distance || smaller)
96926
+ best = { element: element2, distance };
96927
+ }
96928
+ if (!best)
96929
+ return none;
96930
+ const { element } = best;
96931
+ const id = element.id?.trim();
96932
+ if (id && elements.filter((other) => other.id === element.id).length === 1) {
96933
+ return { decision: { ...decision, id, label: element.label.trim() || undefined }, element };
96934
+ }
96935
+ return {
96936
+ decision: {
96937
+ ...decision,
96938
+ x: Math.round(element.x + element.width / 2),
96939
+ y: Math.round(element.y + element.height / 2)
96940
+ },
96941
+ element
96942
+ };
96943
+ }
96944
+ function forceScreenshotTap(decision) {
96945
+ if (decision.type !== "tap" && decision.type !== "type" && decision.type !== "input") {
96946
+ return decision;
96947
+ }
96948
+ if (isSystemPermissionLabel(decision.label))
96949
+ return decision;
96950
+ const { id: _treeId, label: _treeLabel, ...point } = decision;
96951
+ return point;
96952
+ }
96953
+ var SCREENSHOT_POINT_RETRY = "Send x and y (0\u20131000) for the centre of the control on the attached screenshot. Do not send id or label.";
96954
+ function screenshotPointMissing(decision) {
96955
+ if (decision.type !== "tap")
96956
+ return false;
96957
+ if (isSystemPermissionLabel(decision.label))
96958
+ return false;
96959
+ if (decision.description)
96960
+ return false;
96961
+ return decision.x == null || decision.y == null;
96962
+ }
96963
+ function repeatTapHint(x, y, taps, mode) {
96964
+ const where = `${Math.round(x)},${Math.round(y)}`;
96965
+ const advice = mode === "tree" ? "A screen snapshot is attached this time: if it lists the control, tap it by its id instead of guessing a point. If it does not, trust the screenshot." : mode === "grid" ? "The control probably sits near the edge of the cell: pick the neighbouring cell, or qx/qy 0\u20131 or 8\u20139, so the point moves toward it." : "Re-read the screenshot and aim at the exact centre of the control; a small control such as a close X is easy to miss by a few percent.";
96966
+ return `You tapped ${where} ${taps} times and the screen did not change, so that point is not on the control. Do not tap it again. ${advice}`;
96967
+ }
96968
+ function stuckWaitHint(waits) {
96969
+ return `The screen has not changed after ${waits} waits. Stop waiting and act on what is visible in the screenshot; a system sheet or dialog may be waiting for a tap.`;
96970
+ }
96971
+ function namedGridIndex(value, max2) {
96972
+ if (value == null || !Number.isFinite(value))
96973
+ return null;
96974
+ const rounded = Math.round(value);
96975
+ if (rounded < 0 || rounded > max2)
96976
+ return null;
96977
+ return rounded;
96978
+ }
96979
+ var GRID_CELL_RETRY = "Send col and row (0\u20139) of the labeled cell that contains the centre of the control, and qx and qy (0\u20139) for where inside that cell it sits, in tenths of the cell. Do not send x or y.";
96980
+ function gridPointMissing(decision) {
96981
+ if (decision.type !== "tap" && decision.type !== "type" && decision.type !== "input" && decision.type !== "drag") {
96982
+ return false;
96983
+ }
96984
+ if (isSystemPermissionLabel(decision.label))
96985
+ return false;
96986
+ if (namedGridIndex(decision.col, 9) == null || namedGridIndex(decision.row, 9) == null) {
96987
+ return true;
96988
+ }
96989
+ if (decision.type === "drag") {
96990
+ return namedGridIndex(decision.col2, 9) == null || namedGridIndex(decision.row2, 9) == null;
96991
+ }
96992
+ return false;
96993
+ }
96994
+ function applyGridPoint(decision) {
96995
+ if (decision.type !== "tap" && decision.type !== "type" && decision.type !== "input" && decision.type !== "drag" && decision.type !== "swipe") {
96996
+ return decision;
96997
+ }
96998
+ const col = namedGridIndex(decision.col, 9);
96999
+ const row = namedGridIndex(decision.row, 9);
97000
+ if (col == null || row == null)
97001
+ return decision;
97002
+ const start = pointFromGridCell({ col, row, qx: decision.qx, qy: decision.qy });
97003
+ const next = { ...decision, x: start.x, y: start.y };
97004
+ const col2 = namedGridIndex(decision.col2, 9);
97005
+ const row2 = namedGridIndex(decision.row2, 9);
97006
+ if (col2 != null && row2 != null) {
97007
+ const end = pointFromGridCell({ col: col2, row: row2, qx: decision.qx2, qy: decision.qy2 });
97008
+ next.x2 = end.x;
97009
+ next.y2 = end.y;
97010
+ }
97011
+ return next;
97012
+ }
96316
97013
  function formatScreenSnapshot(elements) {
96317
97014
  if (elements == null)
96318
97015
  return "(screen tree unavailable)";
96319
97016
  if (elements.length === 0)
96320
97017
  return "(empty tree)";
96321
- const ordered = [...elements].sort((a, b) => a.y - b.y || a.x - b.x);
97018
+ const usable = elements.filter((element) => !isFullBleedElement(element));
97019
+ if (usable.length === 0) {
97020
+ return "(empty tree \u2014 full-screen canvas or game surface only. Ignore it. Use screenshot x,y for the centre of the control.)";
97021
+ }
97022
+ const ordered = [...usable].sort((a, b) => a.y - b.y || a.x - b.x);
96322
97023
  const shown = ordered.slice(0, MAX_SCREEN_SNAPSHOT_ROWS);
96323
97024
  const lines = shown.map((element) => {
96324
97025
  const id = element.id?.trim() ? ` id=${element.id.trim()}` : "";
@@ -96498,13 +97199,44 @@ function prefersScreenshotTap(decision) {
96498
97199
  return false;
96499
97200
  return decision.x != null && decision.y != null;
96500
97201
  }
96501
- var SYSTEM_PROMPT = `You are a mobile QA agent controlling an app via screenshots.
96502
- A screenshot of the current device screen is ALWAYS attached to the user message as an image. You can see it. Never claim that no screenshot was provided, missing, blank, or unavailable.
96503
-
96504
- Your entire reply MUST be a single strict JSON object (double quotes only \u2014 never single quotes) and nothing else \u2014 no markdown fences, no commentary before or after.
97202
+ var REPLY_FORMAT = `Your entire reply MUST be a single strict JSON object (double quotes only \u2014 never single quotes) and nothing else \u2014 no markdown fences, no commentary before or after.
96505
97203
  Every action MUST include:
96506
97204
  - "reason": one short sentence summarizing the action choice
96507
- - "thoughts": 2\u20134 sentences describing what you see on screen and how that led to this action
97205
+ - "thoughts": 2\u20134 sentences describing what you see on screen and how that led to this action`;
97206
+ var APP_LIFECYCLE_SHAPES = `{"type":"activate-app","appId":"com.example.app","reason":"...","thoughts":"..."}
97207
+ {"type":"terminate-app","appId":"com.example.app","reason":"...","thoughts":"..."}
97208
+ {"type":"restart-app","appId":"com.example.app","reason":"...","thoughts":"..."}
97209
+ {"type":"background-app","seconds":3,"reason":"...","thoughts":"..."}
97210
+ {"type":"open-url","url":"https://example.com","reason":"...","thoughts":"..."}
97211
+ {"type":"assert","assertion":"visible|not-visible","text":"...","reason":"...","thoughts":"..."}
97212
+ {"type":"wait","ms":500-3000,"reason":"...","thoughts":"..."}
97213
+ {"type":"verify","reason":"...","thoughts":"..."}
97214
+ {"type":"done","reason":"...","thoughts":"..."}
97215
+ {"type":"fail","reason":"...","thoughts":"..."}`;
97216
+ var KEYBOARD_GUIDANCE = `Textfield input and keyboard handling:
97217
+ - Typing into a textfield brings up the virtual soft keyboard on the bottom of the screen (typically covering y ~ 550\u20131000), which may obscure buttons (like Submit, Login, Next) or lower inputs.
97218
+ - When typing inside a textfield or when the keyboard is open:
97219
+ 1. Dismiss keyboard by tapping away: Emit a tap on an empty, neutral background area outside textfields and above the keyboard (e.g. tap neutral header space or empty background) to dismiss the keyboard.
97220
+ 2. Nextline / Return on keyboard: Tap the Return / Done / Next / Search key on the virtual keyboard itself (using its x,y coordinates), or append "\\n" to your input text.
97221
+ 3. Dismiss then use app buttons: If an app button you need is obscured or covered by the soft keyboard, dismiss the keyboard first (by tapping away or pressing Return) so the button is revealed, then tap the app button on the subsequent step.`;
97222
+ var SWIPE_GUIDANCE = `Swipe direction is the FINGER movement (same as Inspector):
97223
+ - "down" (finger down, y 200\u2192800): scroll UP \u2014 reveal content above
97224
+ - "up" (finger up, y 800\u2192200): scroll DOWN \u2014 reveal content below / to the bottom
97225
+ - "left" / "right": horizontal paging
97226
+ When the case says "scroll up", emit direction "down". When it says "scroll down" or "to the bottom", emit direction "up". Never tap to scroll. Never emit wait as a stand-in for scrolling.`;
97227
+ var SHARED_RULES = `- activate-app / terminate-app / restart-app / open-url / background-app match yoqa action. Omit appId to use the catalog app id provided in the user message.
97228
+ - To scroll a list or page, emit swipe \u2014 never a tap in the middle of the screen as a stand-in for scrolling.
97229
+ - "Scroll until you cannot scroll anymore" / "to the bottom": keep emitting swipe (direction "up") until a later screenshot shows the same content. Then verify. Unchanged screens after taps do not count \u2014 taps do not scroll.
97230
+ - You are given ONE current instruction. Complete only that instruction, then return verify or done. Later instructions are hidden on purpose \u2014 do not invent, skip ahead, or combine them. Earlier finished instructions may be omitted \u2014 do not reconstruct them.
97231
+ - Do not open unrelated tabs or screens (Wallet, Profile, \u2026) unless the current instruction says to. If the current target is not visible, scroll THIS screen to find it. If it cannot be found, fail with what you see.
97232
+ - When THIS instruction's expected result is already visible, return verify or done. That finishes this instruction only, not the whole case. Otherwise return the next action. A separate verify check looks at the same screenshot after you decide.
97233
+ - Splash / loading / blank screens: return wait (do not tap imaginary tabs).
97234
+ - Do not repeat the same tap target from Recent actions unless the screenshot still shows you are on the wrong screen.
97235
+ - On fail, say what you see (e.g. "splash logo only, no bottom tabs") \u2014 never "no screenshot".`;
97236
+ var SYSTEM_PROMPT = `You are a mobile QA agent controlling an app using BOTH visual screenshots and screen snapshots (accessibility tree).
97237
+ A screenshot of the current device screen is ALWAYS attached to the user message as an image. You can see it. Never claim that no screenshot was provided, missing, blank, or unavailable.
97238
+
97239
+ ${REPLY_FORMAT}
96508
97240
 
96509
97241
  Valid shapes:
96510
97242
  {"type":"tap","label":"Allow","reason":"...","thoughts":"..."}
@@ -96519,45 +97251,63 @@ Valid shapes:
96519
97251
  {"type":"type","text":"...","x":0-1000,"y":0-1000,"reason":"...","thoughts":"..."}
96520
97252
  {"type":"input","text":"...","id":"email_field","reason":"...","thoughts":"..."}
96521
97253
  {"type":"alert","alertAction":"accept","reason":"...","thoughts":"..."}
96522
- {"type":"activate-app","appId":"com.example.app","reason":"...","thoughts":"..."}
96523
- {"type":"terminate-app","appId":"com.example.app","reason":"...","thoughts":"..."}
96524
- {"type":"restart-app","appId":"com.example.app","reason":"...","thoughts":"..."}
96525
- {"type":"background-app","seconds":3,"reason":"...","thoughts":"..."}
96526
- {"type":"open-url","url":"https://example.com","reason":"...","thoughts":"..."}
96527
- {"type":"assert","assertion":"visible|not-visible","text":"...","reason":"...","thoughts":"..."}
96528
- {"type":"wait","ms":500-3000,"reason":"...","thoughts":"..."}
96529
- {"type":"verify","reason":"...","thoughts":"..."}
96530
- {"type":"done","reason":"...","thoughts":"..."}
96531
- {"type":"fail","reason":"...","thoughts":"..."}
97254
+ ${APP_LIFECYCLE_SHAPES}
96532
97255
 
96533
97256
  Coordinates use a 0\u20131000 normalized grid (0,0 top-left of the attached screenshot). The user message also includes a screen snapshot (cleaned accessibility tree) with the same grid \u2014 x,y is each element's TOP-LEFT, not centre. Centre = x + width/2, y + height/2.
96534
97257
 
96535
97258
  Use BOTH the screenshot and the screen snapshot:
96536
- - Prefer snapshot "id=\u2026" when present (same as yoqa action tap --id).
97259
+ - Visual context (screenshot): Observe visual layout, colors, icons, canvas controls, modal overlays, and soft keyboard visibility.
97260
+ - Structural context (screen snapshot): Inspect accessibility ids ("id=..."), element boundaries, and text labels.
97261
+ - Decide whether to target by "id" or "x,y":
97262
+ * Use "id" when the snapshot provides a clean, unique, and unambiguous identifier for the element.
97263
+ * Use "x,y" coordinates (centre of the control on the screenshot grid) when controls lack IDs, are custom-drawn icons/canvas, or when coordinates are more direct and reliable than broad container IDs.
96537
97264
  - System permission / notification dialogs: {"type":"tap","label":"Allow"} or {"type":"alert","alertAction":"accept"}. Never guess coordinates for these.
96538
- - For other in-app controls, tap screenshot x,y of the visible control if there is no id.
96539
- - If the snapshot is empty or the control is only drawn (no label/id), use the screenshot.
96540
97265
  - If a previous action failed (see Last action error), pick a different target from the snapshot + screenshot \u2014 do not repeat the same id/label/point.
96541
97266
 
96542
- Swipe direction is the FINGER movement (same as Inspector):
96543
- - "down" (finger down, y 200\u2192800): scroll UP \u2014 reveal content above
96544
- - "up" (finger up, y 800\u2192200): scroll DOWN \u2014 reveal content below / to the bottom
96545
- - "left" / "right": horizontal paging
96546
- When the case says "scroll up", emit direction "down". When it says "scroll down" or "to the bottom", emit direction "up". Never tap to scroll. Never emit wait as a stand-in for scrolling.
97267
+ ${KEYBOARD_GUIDANCE}
97268
+
97269
+ ${SWIPE_GUIDANCE}
96547
97270
 
96548
97271
  Rules:
96549
97272
  - Look at the attached screenshot AND the screen snapshot before deciding.
96550
97273
  - Double-tap: {"type":"tap","double":true,...}. Long-press: {"type":"tap","durationMs":2000,...}. Drag a slider/chip with type drag, not swipe.
96551
- - activate-app / terminate-app / restart-app / open-url / background-app match yoqa action. Omit appId to use the catalog app id provided in the user message.
96552
97274
  - Assert text on the tree with {"type":"assert","assertion":"visible","text":"\u2026"}.
96553
- - To scroll a list or page, emit swipe \u2014 never a tap in the middle of the screen as a stand-in for scrolling.
96554
- - "Scroll until you cannot scroll anymore" / "to the bottom": keep emitting swipe (direction "up") until a later screenshot shows the same content. Then verify. Unchanged screens after taps do not count \u2014 taps do not scroll.
96555
- - You are given ONE current instruction. Complete only that instruction, then return verify or done. Later instructions are hidden on purpose \u2014 do not invent, skip ahead, or combine them.
96556
- - Do not open unrelated tabs or screens (Wallet, Profile, \u2026) unless the current instruction says to. If the current target is not visible, scroll THIS screen to find it. If it cannot be found, fail with what you see.
96557
- - When THIS instruction's expected result is visible (or the action is done and the UI has updated), return verify or done. That finishes this instruction only, not the whole case.
96558
- - Splash / loading / blank screens: return wait (do not tap imaginary tabs).
96559
- - Do not repeat the same tap target from Recent actions unless the screenshot still shows you are on the wrong screen.
96560
- - On fail, say what you see (e.g. "splash logo only, no bottom tabs") \u2014 never "no screenshot".`;
97275
+ ${SHARED_RULES}`;
97276
+ var VISION_SYSTEM_PROMPT = `You are a mobile QA agent controlling an app by looking at screenshots.
97277
+ A screenshot of the current device screen is ALWAYS attached to the user message as an image. You can see it. Never claim that no screenshot was provided, missing, blank, or unavailable. No accessibility tree is provided \u2014 decide from the screenshot alone.
97278
+
97279
+ ${REPLY_FORMAT}
97280
+
97281
+ Valid shapes:
97282
+ {"type":"tap","x":0-1000,"y":0-1000,"reason":"...","thoughts":"..."}
97283
+ {"type":"tap","x":0-1000,"y":0-1000,"double":true,"reason":"...","thoughts":"..."}
97284
+ {"type":"tap","x":0-1000,"y":0-1000,"durationMs":2000,"reason":"...","thoughts":"..."}
97285
+ {"type":"tap","label":"Allow","reason":"...","thoughts":"..."}
97286
+ {"type":"swipe","direction":"up|down|left|right","reason":"...","thoughts":"..."}
97287
+ {"type":"swipe","x":0-1000,"y":0-1000,"x2":0-1000,"y2":0-1000,"reason":"...","thoughts":"..."}
97288
+ {"type":"drag","x":0-1000,"y":0-1000,"x2":0-1000,"y2":0-1000,"reason":"...","thoughts":"..."}
97289
+ {"type":"type","text":"...","x":0-1000,"y":0-1000,"reason":"...","thoughts":"..."}
97290
+ {"type":"alert","alertAction":"accept","reason":"...","thoughts":"..."}
97291
+ ${APP_LIFECYCLE_SHAPES}
97292
+
97293
+ Coordinates use a 0\u20131000 normalized grid over the attached screenshot (0,0 is the top-left corner, 1000,1000 the bottom-right). Send x,y for the CENTRE of the control you want to hit. Read it off the screenshot: a control's centre is halfway between its left and right edges, and halfway between its top and bottom edges.
97294
+
97295
+ Targeting:
97296
+ - Tap with "x,y" on the screenshot. Do not send id \u2014 no ids are available. Do not send description.
97297
+ - System permission / notification dialogs: {"type":"tap","label":"Allow"} or {"type":"alert","alertAction":"accept"}. Never guess coordinates for these.
97298
+ - Visual context is all you have: read layout, colors, icons, canvas controls, modal overlays, and soft keyboard visibility from the screenshot.
97299
+ - If a previous action failed (see Last action error), pick a different point on the screenshot \u2014 do not repeat the same point.
97300
+ - If a magenta grid is drawn on the screenshot, each cell is labeled column-row in its top-left (0-0 through 9-9). Then send col, row, qx, and qy for the centre of the control, and do not send x or y. qx and qy are 0\u20139, tenths of the cell from its left and top (4 or 5 is the middle). A small control near a cell edge needs qx or qy near 0 or 9, so do not default to the middle. The tap point is computed from that cell.
97301
+
97302
+ ${KEYBOARD_GUIDANCE}
97303
+
97304
+ ${SWIPE_GUIDANCE}
97305
+
97306
+ Rules:
97307
+ - Look at the attached screenshot before deciding.
97308
+ - Double-tap: {"type":"tap","double":true,...}. Long-press: {"type":"tap","durationMs":2000,...}. Drag a slider/chip with type drag, not swipe.
97309
+ - To check that text is on screen, emit {"type":"assert","assertion":"visible","text":"\u2026"} with the exact visible text.
97310
+ ${SHARED_RULES}`;
96561
97311
  function formatRecentActions(actions) {
96562
97312
  if (actions.length === 0)
96563
97313
  return "(none \u2014 first step)";
@@ -96604,6 +97354,11 @@ function formatRecentActions(actions) {
96604
97354
  `);
96605
97355
  }
96606
97356
  var LIST_ITEM_START_RE = /^(?:\d+[.)]|\d+\s*[-\u2013]|[-*\u2022])\s+/;
97357
+ function continuesInstruction(rawLine, line) {
97358
+ if (/^\s/.test(rawLine))
97359
+ return true;
97360
+ return /^[a-z]/.test(line);
97361
+ }
96607
97362
  function splitInstructionSteps(instructions) {
96608
97363
  const trimmed = instructions.replace(/\r\n/g, `
96609
97364
  `).trim();
@@ -96613,7 +97368,7 @@ function splitInstructionSteps(instructions) {
96613
97368
  `);
96614
97369
  const items = [];
96615
97370
  let current = [];
96616
- let sawList = false;
97371
+ let sawSteps = false;
96617
97372
  const pushCurrent = () => {
96618
97373
  const text3 = current.join(" ").replace(/\s+/g, " ").trim();
96619
97374
  if (text3)
@@ -96622,12 +97377,21 @@ function splitInstructionSteps(instructions) {
96622
97377
  };
96623
97378
  for (const rawLine of lines) {
96624
97379
  const line = rawLine.trim();
96625
- if (!line)
97380
+ if (!line) {
97381
+ if (current.length > 0) {
97382
+ sawSteps = true;
97383
+ pushCurrent();
97384
+ }
96626
97385
  continue;
97386
+ }
96627
97387
  if (LIST_ITEM_START_RE.test(line)) {
96628
- sawList = true;
97388
+ sawSteps = true;
96629
97389
  pushCurrent();
96630
97390
  current = [line.replace(LIST_ITEM_START_RE, "").trim()];
97391
+ } else if (current.length > 0 && !continuesInstruction(rawLine, line)) {
97392
+ sawSteps = true;
97393
+ pushCurrent();
97394
+ current = [line];
96631
97395
  } else if (current.length > 0) {
96632
97396
  current.push(line);
96633
97397
  } else {
@@ -96635,7 +97399,7 @@ function splitInstructionSteps(instructions) {
96635
97399
  }
96636
97400
  }
96637
97401
  pushCurrent();
96638
- if (!sawList)
97402
+ if (!sawSteps)
96639
97403
  return [trimmed];
96640
97404
  return items.length > 0 ? items : [trimmed];
96641
97405
  }
@@ -96654,11 +97418,29 @@ function flattenCaseInstructions(flows2) {
96654
97418
  }
96655
97419
  return queue.length > 0 ? queue : [{ instructions: "", expectedResult: "" }];
96656
97420
  }
97421
+ var MAX_COMPLETED_IN_PROMPT = 3;
96657
97422
  function formatCompletedInstructions(completed) {
96658
97423
  if (completed.length === 0)
96659
97424
  return "(none \u2014 this is the first instruction)";
96660
- return completed.map((text3, index) => `${index + 1}. ${text3}`).join(`
97425
+ const omitted = Math.max(0, completed.length - MAX_COMPLETED_IN_PROMPT);
97426
+ const tail2 = completed.slice(-MAX_COMPLETED_IN_PROMPT);
97427
+ const lines = tail2.map((text3, index) => `${omitted + index + 1}. ${text3}`);
97428
+ if (omitted === 0)
97429
+ return lines.join(`
96661
97430
  `);
97431
+ return `${omitted} earlier instructions already done (omitted \u2014 do not reconstruct or repeat them).
97432
+ ${lines.join(`
97433
+ `)}`;
97434
+ }
97435
+ var MAX_APP_KNOWLEDGE_CHARS = 2048;
97436
+ function formatAppKnowledge(knowledge) {
97437
+ const trimmed = (knowledge ?? "").trim();
97438
+ if (!trimmed)
97439
+ return null;
97440
+ if (trimmed.length <= MAX_APP_KNOWLEDGE_CHARS)
97441
+ return trimmed;
97442
+ return `${trimmed.slice(0, MAX_APP_KNOWLEDGE_CHARS)}
97443
+ \u2026 (app knowledge truncated at ${MAX_APP_KNOWLEDGE_CHARS} characters)`;
96662
97444
  }
96663
97445
  function formatDecidePrompt(input) {
96664
97446
  const ordinal = input.instructionOrdinal ?? input.stepIndex + 1;
@@ -96667,8 +97449,13 @@ function formatDecidePrompt(input) {
96667
97449
  const expected = input.expectedResult.trim() ? input.expectedResult : "(none for this instruction \u2014 return verify once you have performed it and the UI has updated)";
96668
97450
  const progress = total > 0 ? `Instruction ${ordinal} of ${total} \u2014 do ONLY this instruction${remaining > 0 ? `; ${remaining} remain after it` : " (last instruction)"}` : `Step ${input.stepIndex + 1}`;
96669
97451
  const later = remaining > 0 ? `Later instructions exist (${remaining}) but are hidden. Do not invent or perform them. Do not open unrelated screens to skip ahead.` : "This is the last instruction of the case.";
97452
+ const appKnowledge = formatAppKnowledge(input.appKnowledge);
96670
97453
  return [
96671
97454
  `App context: ${input.appContext || "(none)"}`,
97455
+ ...appKnowledge ? [
97456
+ `App knowledge (notes your team keeps about this app \u2014 trust these over guesses):
97457
+ ${appKnowledge}`
97458
+ ] : [],
96672
97459
  `Test case: ${input.caseTitle}`,
96673
97460
  progress,
96674
97461
  "Completed instructions (already done \u2014 do not repeat):",
@@ -96680,9 +97467,19 @@ function formatDecidePrompt(input) {
96680
97467
  `Recent actions:
96681
97468
  ${formatRecentActions(input.recentActions ?? [])}`,
96682
97469
  `Last action error: ${input.lastError || "(none)"}`,
96683
- "Screen snapshot (cleaned accessibility tree, 0\u20131000 grid, x,y is top-left):",
96684
- input.screenSnapshot || "(unavailable)",
96685
- "Look at the attached screenshot image AND the screen snapshot and decide the next action. A screenshot is attached.",
97470
+ ...input.screenshotOnly ? [
97471
+ "No accessibility tree is attached. The attached screenshot was taken first and is all you have.",
97472
+ "Name the centre of the control on that screenshot as x,y (0\u20131000). Do not send id. Send label only for a system permission or notification button."
97473
+ ] : [
97474
+ "The screenshot is the ground truth. The tree below can miss a system sheet or dialog that covers the app, and can describe the screen underneath it. If they disagree, trust the screenshot.",
97475
+ "Screen snapshot (cleaned accessibility tree, 0\u20131000 grid, x,y is top-left):",
97476
+ input.screenSnapshot || "(unavailable)",
97477
+ "Look at the attached screenshot image AND the screen snapshot and decide the next action. Decide whether to use x,y coordinates or an id based on both sources. A screenshot is attached."
97478
+ ],
97479
+ ...input.coordGrid ? [
97480
+ "A magenta grid is drawn on the screenshot. Each cell is labeled in its top-left corner as column-row (0-0 is the top-left cell, 9-9 is the bottom-right). Do not send x or y. Send col and row (0\u20139) of the cell that contains the centre of the control, and qx and qy (0\u20139) for where inside that cell the centre sits, in tenths of the cell: 0 is the left or top edge, 4 or 5 is the middle, 9 is the right or bottom edge. A small control near a cell edge needs qx or qy near 0 or 9, so do not default to the middle. The tap point is computed from those values. For a drag, also send col2, row2, qx2, and qy2 for the end."
97481
+ ] : [],
97482
+ `Again, do ONLY this: ${input.instructions || "(none)"}`,
96686
97483
  'Reply with ONLY the JSON action object, including non-empty "reason" and "thoughts".'
96687
97484
  ].join(`
96688
97485
  `);
@@ -96693,12 +97490,51 @@ function isAbsurdNoScreenshotFail(decision) {
96693
97490
  const text3 = `${decision.reason} ${decision.thoughts}`.toLowerCase();
96694
97491
  return text3.includes("no screenshot") || text3.includes("screenshot provided") || text3.includes("screenshot was not") || text3.includes("without a screenshot") || text3.includes("missing screenshot") || text3.includes("screenshot unavailable") || text3.includes("cannot see the screen") || text3.includes("no image");
96695
97492
  }
97493
+ var VERIFY_PROMPT = `You check whether ONE mobile test instruction is already satisfied.
97494
+ A screenshot of the device AFTER the last action is attached. You can see it.
97495
+ Reply with ONLY one JSON object: {"type":"verify"|"continue"|"fail","reason":"...","thoughts":"..."}
97496
+ - verify: the current instruction's expected result is visible, or the instruction has clearly been performed.
97497
+ - continue: the instruction is not finished. Do not invent the next tap.
97498
+ - fail: the instruction cannot be completed from what is on screen.
97499
+ Do not propose coordinates. Do not skip ahead to later instructions.`;
97500
+ function formatVerifyPrompt(input) {
97501
+ const ordinal = input.instructionOrdinal ?? 1;
97502
+ const total = input.instructionCount ?? 0;
97503
+ const progress = total > 0 ? `Instruction ${ordinal} of ${total}.` : "";
97504
+ const expected = input.expectedResult.trim() ? input.expectedResult : "(none \u2014 verify once this instruction has been performed and the UI has updated)";
97505
+ return [
97506
+ `Test case: ${input.caseTitle}`,
97507
+ progress,
97508
+ `Instruction to verify: ${input.instructions || "(none)"}`,
97509
+ `Expected result: ${expected}`,
97510
+ "Look at the attached screenshot. Is this instruction satisfied now?"
97511
+ ].filter((line) => line.length > 0).join(`
97512
+ `);
97513
+ }
97514
+ async function verifyInstruction(input) {
97515
+ const verdictSchema = exports_external.object({
97516
+ type: exports_external.union([exports_external.literal("verify"), exports_external.literal("continue"), exports_external.literal("fail")]),
97517
+ reason: exports_external.string().min(1),
97518
+ thoughts: exports_external.string().min(1)
97519
+ });
97520
+ const object3 = await completeVision(input.auth, {
97521
+ schema: verdictSchema,
97522
+ system: VERIFY_PROMPT,
97523
+ prompt: formatVerifyPrompt(input),
97524
+ imageBase64: input.imageBase64,
97525
+ image: input.image,
97526
+ onDecideRetry: input.onDecideRetry
97527
+ });
97528
+ return object3;
97529
+ }
96696
97530
  async function decideNextAction(input) {
96697
97531
  return completeVision(input.auth, {
96698
97532
  schema: agentDecisionSchema,
96699
- system: SYSTEM_PROMPT,
97533
+ system: input.screenshotOnly ? VISION_SYSTEM_PROMPT : SYSTEM_PROMPT,
96700
97534
  prompt: formatDecidePrompt(input),
96701
- imageBase64: input.imageBase64
97535
+ imageBase64: input.imageBase64,
97536
+ image: input.image,
97537
+ onDecideRetry: input.onDecideRetry
96702
97538
  });
96703
97539
  }
96704
97540
 
@@ -96865,6 +97701,7 @@ function mapApp(row) {
96865
97701
  name: row.name,
96866
97702
  prefix: row.prefix || row.id,
96867
97703
  context: row.context,
97704
+ knowledge: row.knowledge,
96868
97705
  iosBundleId: row.iosBundleId,
96869
97706
  iosAppStoreId: row.iosAppStoreId,
96870
97707
  androidApplicationId: row.androidApplicationId,
@@ -97050,6 +97887,7 @@ async function createApp(input) {
97050
97887
  name: name30,
97051
97888
  prefix,
97052
97889
  context: "",
97890
+ knowledge: "",
97053
97891
  iosBundleId: "",
97054
97892
  iosAppStoreId: "",
97055
97893
  androidApplicationId: "",
@@ -97063,6 +97901,7 @@ async function createApp(input) {
97063
97901
  }
97064
97902
  return created;
97065
97903
  }
97904
+ var MAX_APP_KNOWLEDGE_CHARS2 = 2048;
97066
97905
  async function updateApp(appId, input) {
97067
97906
  const db = getCatalogDb();
97068
97907
  const existing = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
@@ -97073,11 +97912,15 @@ async function updateApp(appId, input) {
97073
97912
  if (!name30) {
97074
97913
  throw new CatalogValidationError("Application name is required");
97075
97914
  }
97915
+ if (input.knowledge !== undefined && input.knowledge.length > MAX_APP_KNOWLEDGE_CHARS2) {
97916
+ throw new CatalogValidationError(`App knowledge must be at most ${MAX_APP_KNOWLEDGE_CHARS2} characters`);
97917
+ }
97076
97918
  const prefix = input.prefix !== undefined ? await allocatePrefix(input.prefix.trim() || name30, appId) : existing.prefix || await allocatePrefix(name30, appId);
97077
97919
  await db.update(apps).set({
97078
97920
  name: name30,
97079
97921
  prefix,
97080
97922
  context: input.context ?? existing.context,
97923
+ knowledge: input.knowledge ?? existing.knowledge,
97081
97924
  iosBundleId: input.iosBundleId ?? existing.iosBundleId,
97082
97925
  iosAppStoreId: input.iosAppStoreId ?? existing.iosAppStoreId,
97083
97926
  androidApplicationId: input.androidApplicationId ?? existing.androidApplicationId,
@@ -97607,8 +98450,8 @@ function createDevicesRoutes() {
97607
98450
  }
97608
98451
 
97609
98452
  // src/domains/doctor/application.ts
97610
- import { homedir as homedir10 } from "os";
97611
- import { join as join13 } from "path";
98453
+ import { homedir as homedir11 } from "os";
98454
+ import { join as join14 } from "path";
97612
98455
  async function runCommand5(command, options) {
97613
98456
  ensureHostToolPath();
97614
98457
  try {
@@ -97694,7 +98537,7 @@ async function probeJava() {
97694
98537
  let detail = javaBin ?? "java not on PATH";
97695
98538
  if (javaHome)
97696
98539
  detail = `JAVA_HOME=${javaHome}`;
97697
- else if (await Bun.file(join13(studioJbr, "bin/java")).exists()) {
98540
+ else if (await Bun.file(join14(studioJbr, "bin/java")).exists()) {
97698
98541
  detail = `Android Studio JBR available at ${studioJbr} (JAVA_HOME unset)`;
97699
98542
  }
97700
98543
  const ok = Boolean(javaBin || javaHome);
@@ -97725,7 +98568,7 @@ async function probeXcodeSelect() {
97725
98568
  };
97726
98569
  }
97727
98570
  async function probeAdbDevices() {
97728
- const adb = await which3("adb") ?? join13(homedir10(), "Library/Android/sdk/platform-tools/adb");
98571
+ const adb = await which3("adb") ?? join14(homedir11(), "Library/Android/sdk/platform-tools/adb");
97729
98572
  if (!await Bun.file(adb).exists() && !await which3("adb")) {
97730
98573
  return {
97731
98574
  id: "adb-devices",
@@ -97926,10 +98769,10 @@ import {
97926
98769
  randomBytes
97927
98770
  } from "crypto";
97928
98771
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
97929
- import { homedir as homedir11 } from "os";
97930
- import { dirname as dirname3, join as join14 } from "path";
97931
- var YOQA_ROOT5 = join14(homedir11(), ".yoqa");
97932
- var KEY_PATH = join14(YOQA_ROOT5, ".provider-key");
98772
+ import { homedir as homedir12 } from "os";
98773
+ import { dirname as dirname3, join as join15 } from "path";
98774
+ var YOQA_ROOT5 = join15(homedir12(), ".yoqa");
98775
+ var KEY_PATH = join15(YOQA_ROOT5, ".provider-key");
97933
98776
  var ALGORITHM = "aes-256-gcm";
97934
98777
  var IV_LENGTH = 12;
97935
98778
  var KEY_LENGTH = 32;
@@ -98721,13 +99564,12 @@ class ActionNotFoundError extends Error {
98721
99564
  this.name = "ActionNotFoundError";
98722
99565
  }
98723
99566
  }
98724
- async function performAction(session, body) {
99567
+ async function performAction(session, body, options) {
98725
99568
  const locatorTap = Boolean((body.id || body.label) && (body.kind === "tap" || body.kind === "input"));
98726
99569
  let x = body.x;
98727
99570
  let y = body.y;
98728
99571
  if (locatorTap) {
98729
- const screen = await getScreen(session, { full: false });
98730
- const elements = screen.elements ?? [];
99572
+ const elements = options?.screenElements ?? (await getScreen(session, { full: false })).elements ?? [];
98731
99573
  const match2 = body.id ? findElementById(elements, body.id) : findElementByLabel(elements, body.label ?? "");
98732
99574
  if (!match2) {
98733
99575
  throw new ActionNotFoundError(body.id ? `No element matching id: ${body.id}` : `No element matching label: ${body.label}`);
@@ -98827,6 +99669,11 @@ async function performAction(session, body) {
98827
99669
  // src/domains/runs/case-executor.ts
98828
99670
  var MAX_STEPS_PER_CASE = 25;
98829
99671
  var POST_ACTION_SETTLE_MS = 800;
99672
+ var GRID_ESCALATION_TAPS = 4;
99673
+ var TREE_ASSIST_ACTIONS = 2;
99674
+ var STUCK_WAITS = 3;
99675
+ var REPEAT_TAPS = 2;
99676
+ var REPEAT_TAP_RADIUS = 30;
98830
99677
  var defaultClock = {
98831
99678
  sleep: (ms) => new Promise((resolve7) => setTimeout(resolve7, ms)),
98832
99679
  now: () => Date.now()
@@ -98873,6 +99720,12 @@ async function runTextAssert(input) {
98873
99720
  await input.clock.sleep(400);
98874
99721
  }
98875
99722
  }
99723
+ function isPointTap(decision) {
99724
+ return decision.type === "tap" && decision.x != null && decision.y != null && !isSystemPermissionLabel(decision.label);
99725
+ }
99726
+ function isDeviceDecision(decision) {
99727
+ return decision.type !== "verify" && decision.type !== "done" && decision.type !== "fail";
99728
+ }
98876
99729
  function commandForDecision(decision, defaultAppId) {
98877
99730
  if (decision.type === "verify" || decision.type === "done" || decision.type === "fail") {
98878
99731
  return null;
@@ -99249,25 +100102,49 @@ async function executeAgentCase(deps) {
99249
100102
  let caseError = null;
99250
100103
  let lastScreenshotUri = null;
99251
100104
  const recordedDecisions = [];
100105
+ let inFlightPhases = null;
100106
+ let inFlightLatencyMs = 0;
99252
100107
  const decideOnce = async (input) => {
99253
100108
  const payload = {
99254
100109
  auth: deps.auth,
99255
100110
  appContext: deps.appContext,
100111
+ appKnowledge: deps.appKnowledge,
99256
100112
  caseTitle: deps.catalogCase.name,
99257
100113
  instructions: input.flow.instructions,
99258
100114
  expectedResult: input.flow.expectedResult,
99259
100115
  stepIndex: stepIdx,
99260
100116
  imageBase64: input.imageBase64,
100117
+ image: input.image,
99261
100118
  recentActions: input.recentActions,
99262
100119
  screenSnapshot: input.screenSnapshot,
99263
100120
  lastError: input.lastError,
99264
100121
  defaultAppId: deps.defaultAppId,
99265
100122
  completedInstructions: [...input.completedInstructions],
99266
100123
  instructionOrdinal: input.instructionOrdinal,
99267
- instructionCount: input.instructionCount
100124
+ instructionCount: input.instructionCount,
100125
+ coordGrid: input.coordGrid,
100126
+ screenshotOnly: input.screenshotOnly,
100127
+ onDecideRetry: input.onDecideRetry
100128
+ };
100129
+ const finish = (raw2) => {
100130
+ let next = coerceScrollIntentToSwipe(raw2);
100131
+ next = continueScrollingInsteadOfComplete({
100132
+ decision: next,
100133
+ instructions: input.flow.instructions,
100134
+ expectedResult: input.flow.expectedResult,
100135
+ recentActions: input.recentActions,
100136
+ lastSwipeMovedScreen: input.lastSwipeMovedScreen
100137
+ });
100138
+ next = releaseCanvasPoint(next, input.screenElements);
100139
+ if (input.screenshotOnly)
100140
+ next = forceScreenshotTap(next);
100141
+ if (input.coordGrid)
100142
+ next = applyGridPoint(next);
100143
+ return next;
99268
100144
  };
99269
100145
  let decision = await decide(payload);
99270
100146
  if (isAbsurdNoScreenshotFail(decision)) {
100147
+ input.onDecideRetry?.();
99271
100148
  decision = await decide(payload);
99272
100149
  if (isAbsurdNoScreenshotFail(decision)) {
99273
100150
  decision = {
@@ -99277,23 +100154,29 @@ async function executeAgentCase(deps) {
99277
100154
  };
99278
100155
  }
99279
100156
  }
99280
- decision = coerceScrollIntentToSwipe(decision);
99281
- decision = continueScrollingInsteadOfComplete({
99282
- decision,
99283
- instructions: input.flow.instructions,
99284
- expectedResult: input.flow.expectedResult,
99285
- recentActions: input.recentActions,
99286
- lastSwipeMovedScreen: input.lastSwipeMovedScreen
99287
- });
100157
+ decision = finish(decision);
100158
+ if (input.coordGrid && gridPointMissing(decision)) {
100159
+ input.onDecideRetry?.();
100160
+ const retried = await decide({ ...payload, lastError: GRID_CELL_RETRY });
100161
+ if (!isAbsurdNoScreenshotFail(retried))
100162
+ decision = finish(retried);
100163
+ } else if (input.screenshotOnly && !input.coordGrid && screenshotPointMissing(decision)) {
100164
+ input.onDecideRetry?.();
100165
+ const retried = await decide({ ...payload, lastError: SCREENSHOT_POINT_RETRY });
100166
+ if (!isAbsurdNoScreenshotFail(retried))
100167
+ decision = finish(retried);
100168
+ }
99288
100169
  if ((decision.type === "activate-app" || decision.type === "terminate-app" || decision.type === "restart-app") && !decision.appId && deps.defaultAppId) {
99289
100170
  decision = { ...decision, appId: deps.defaultAppId };
99290
100171
  }
99291
100172
  return decision;
99292
100173
  };
99293
- const applyDecision = async (decision) => {
100174
+ const applyDecision = async (decision, phases, screenElements) => {
99294
100175
  if (decision.type === "wait") {
99295
100176
  const waitMs = Math.min(3000, Math.max(500, decision.ms ?? 1500));
100177
+ const waitStarted = clock.now();
99296
100178
  await clock.sleep(waitMs);
100179
+ phases.actionMs += clock.now() - waitStarted;
99297
100180
  return "continue";
99298
100181
  }
99299
100182
  if (decision.type === "verify" || decision.type === "done") {
@@ -99305,6 +100188,7 @@ async function executeAgentCase(deps) {
99305
100188
  if (decision.type === "assert") {
99306
100189
  const assertion = decision.assertion === "not-visible" ? "not-visible" : "visible";
99307
100190
  const timeoutMs = Math.min(60000, Math.max(1000, decision.timeoutMs ?? 5000));
100191
+ const assertStarted = clock.now();
99308
100192
  await runTextAssert({
99309
100193
  session: deps.session,
99310
100194
  readScreen: readScreen2,
@@ -99314,14 +100198,18 @@ async function executeAgentCase(deps) {
99314
100198
  text: decision.text ?? "",
99315
100199
  timeoutMs
99316
100200
  });
100201
+ phases.actionMs += clock.now() - assertStarted;
99317
100202
  return "continue";
99318
100203
  }
99319
100204
  const body = decisionToActionRequest(decision, { defaultAppId: deps.defaultAppId });
99320
100205
  if (!body) {
99321
100206
  throw new Error(`${decision.type} is missing required fields`);
99322
100207
  }
100208
+ const actionStarted = clock.now();
99323
100209
  try {
99324
- await perform(deps.session, body);
100210
+ await perform(deps.session, body, {
100211
+ screenElements: screenElements.length > 0 ? screenElements : undefined
100212
+ });
99325
100213
  } catch (error40) {
99326
100214
  if (error40 instanceof ActionNotFoundError && (body.id || body.label) && decision.x != null && decision.y != null) {
99327
100215
  await perform(deps.session, {
@@ -99335,8 +100223,12 @@ async function executeAgentCase(deps) {
99335
100223
  } else {
99336
100224
  throw error40;
99337
100225
  }
100226
+ } finally {
100227
+ phases.actionMs += clock.now() - actionStarted;
99338
100228
  }
100229
+ const settleStarted = clock.now();
99339
100230
  await clock.sleep(settleMs);
100231
+ phases.settleMs += clock.now() - settleStarted;
99340
100232
  return "continue";
99341
100233
  };
99342
100234
  try {
@@ -99344,6 +100236,65 @@ async function executeAgentCase(deps) {
99344
100236
  const recentActions = [];
99345
100237
  const completedInstructions = [];
99346
100238
  let prevFingerprint = null;
100239
+ let lastVision = null;
100240
+ const screenMode = deps.screenMode ?? "vision";
100241
+ const gameApp = isGameApp(deps.appContext, deps.appKnowledge);
100242
+ let gridMode = gameApp;
100243
+ let unchangedActions = 0;
100244
+ let unchangedPointTaps = 0;
100245
+ let unchangedWaits = 0;
100246
+ let repeatTaps = 0;
100247
+ let lastTapPoint = null;
100248
+ const omitsTree = () => screenMode === "vision" || gridMode;
100249
+ const verdictForDecision = async (decision, input) => {
100250
+ if (decision.type === "fail") {
100251
+ return { type: "fail", reason: decision.reason, thoughts: decision.thoughts };
100252
+ }
100253
+ if (decision.type === "verify" || decision.type === "done") {
100254
+ return { type: "verify", reason: decision.reason, thoughts: decision.thoughts };
100255
+ }
100256
+ if (deps.verify) {
100257
+ return deps.verify({
100258
+ auth: deps.auth,
100259
+ appContext: deps.appContext,
100260
+ appKnowledge: deps.appKnowledge,
100261
+ caseTitle: deps.catalogCase.name,
100262
+ instructions: input.flow.instructions,
100263
+ expectedResult: input.flow.expectedResult,
100264
+ stepIndex: stepIdx,
100265
+ imageBase64: input.imageBase64,
100266
+ image: input.image,
100267
+ recentActions: input.recentActions,
100268
+ screenSnapshot: input.screenSnapshot,
100269
+ lastError: input.lastError,
100270
+ defaultAppId: deps.defaultAppId,
100271
+ completedInstructions: [...input.completedInstructions],
100272
+ instructionOrdinal: input.instructionOrdinal,
100273
+ instructionCount: input.instructionCount,
100274
+ coordGrid: input.coordGrid,
100275
+ screenshotOnly: input.screenshotOnly,
100276
+ onDecideRetry: input.onDecideRetry
100277
+ });
100278
+ }
100279
+ if (deps.decide) {
100280
+ return {
100281
+ type: "continue",
100282
+ reason: "Instruction still in progress",
100283
+ thoughts: decision.thoughts
100284
+ };
100285
+ }
100286
+ return verifyInstruction({
100287
+ auth: deps.auth,
100288
+ caseTitle: deps.catalogCase.name,
100289
+ instructions: input.flow.instructions,
100290
+ expectedResult: input.flow.expectedResult,
100291
+ imageBase64: input.imageBase64,
100292
+ image: input.image,
100293
+ instructionOrdinal: input.instructionOrdinal,
100294
+ instructionCount: input.instructionCount,
100295
+ onDecideRetry: input.onDecideRetry
100296
+ });
100297
+ };
99347
100298
  for (let instructionIndex = 0;instructionIndex < instructionQueue.length; instructionIndex++) {
99348
100299
  const instruction = instructionQueue[instructionIndex];
99349
100300
  if (!instruction)
@@ -99353,17 +100304,158 @@ async function executeAgentCase(deps) {
99353
100304
  break;
99354
100305
  }
99355
100306
  let instructionDone = false;
100307
+ let pending = null;
100308
+ let pendingElements = [];
99356
100309
  for (let attempt = 0;attempt < maxSteps && !instructionDone; attempt++) {
99357
100310
  if (deps.isAborted()) {
99358
100311
  caseStatus = "cancelled";
99359
100312
  instructionDone = true;
99360
100313
  break;
99361
100314
  }
99362
- const shotStarted = clock.now();
100315
+ const phases = {
100316
+ captureMs: 0,
100317
+ screenMs: 0,
100318
+ prepareMs: 0,
100319
+ decideMs: 0,
100320
+ actionMs: 0,
100321
+ settleMs: 0,
100322
+ decideRetries: 0
100323
+ };
100324
+ let performed = null;
100325
+ if (pending && isDeviceDecision(pending)) {
100326
+ const command2 = commandForDecision(pending, deps.defaultAppId);
100327
+ try {
100328
+ await withCurrentCommand(setCurrentCommand, command2, async () => {
100329
+ await applyDecision(pending, phases, pendingElements);
100330
+ });
100331
+ performed = pending;
100332
+ } catch (error40) {
100333
+ if (!isRetriableActionError(error40) || deps.isAborted())
100334
+ throw error40;
100335
+ const failed = pending;
100336
+ const retryStarted = clock.now();
100337
+ pending = await decideOnce({
100338
+ flow: instruction,
100339
+ imageBase64: lastVision?.imageBase64 ?? "",
100340
+ image: lastVision?.image ?? { base64: "", mediaType: "image/png" },
100341
+ recentActions: [...recentActions, failed],
100342
+ screenSnapshot: lastVision?.screenSnapshot ?? "",
100343
+ lastError: error40 instanceof Error ? error40.message : String(error40),
100344
+ lastSwipeMovedScreen: false,
100345
+ completedInstructions,
100346
+ instructionOrdinal: instructionIndex + 1,
100347
+ instructionCount: instructionQueue.length,
100348
+ screenElements: pendingElements,
100349
+ coordGrid: lastVision?.coordGrid ?? false,
100350
+ screenshotOnly: lastVision?.screenshotOnly ?? omitsTree(),
100351
+ onDecideRetry: () => {
100352
+ phases.decideRetries += 1;
100353
+ }
100354
+ });
100355
+ phases.decideMs += clock.now() - retryStarted;
100356
+ if (!isDeviceDecision(pending)) {
100357
+ performed = null;
100358
+ const terminal = pending;
100359
+ pending = null;
100360
+ const outcome2 = terminal.type === "fail" ? "fail" : "done";
100361
+ recentActions.push(terminal);
100362
+ recordedDecisions.push(terminal);
100363
+ await deps.appendStep({
100364
+ idx: stepIdx,
100365
+ action: {
100366
+ ...terminal,
100367
+ cycle: {
100368
+ action: failed,
100369
+ decision: terminal,
100370
+ verify: {
100371
+ type: outcome2 === "fail" ? "fail" : "verify",
100372
+ reason: terminal.reason,
100373
+ thoughts: terminal.thoughts
100374
+ }
100375
+ }
100376
+ },
100377
+ screenshotUri: lastScreenshotUri,
100378
+ ok: outcome2 !== "fail",
100379
+ latencyMs: clock.now() - retryStarted,
100380
+ phases,
100381
+ detail: terminal.reason,
100382
+ command: commandForDecision(failed, deps.defaultAppId)
100383
+ });
100384
+ stepIdx += 1;
100385
+ if (outcome2 === "fail") {
100386
+ caseStatus = "errored";
100387
+ caseError = terminal.reason ?? "Agent marked the instruction as failed";
100388
+ } else {
100389
+ completedInstructions.push(instruction.instructions);
100390
+ }
100391
+ instructionDone = true;
100392
+ break;
100393
+ }
100394
+ const retryCommand = commandForDecision(pending, deps.defaultAppId);
100395
+ await withCurrentCommand(setCurrentCommand, retryCommand, async () => {
100396
+ await applyDecision(pending, phases, pendingElements);
100397
+ });
100398
+ recentActions.push(pending);
100399
+ performed = pending;
100400
+ }
100401
+ }
100402
+ let phaseStartedAt = clock.now();
100403
+ const shotStarted = phaseStartedAt;
100404
+ inFlightPhases = phases;
99363
100405
  const shot = await deps.session.screenshot();
99364
100406
  lastScreenshotUri = shot.path;
99365
- const tree = await readCleanedTree(readScreen2, deps.session);
100407
+ const advance = (phase) => {
100408
+ const now = clock.now();
100409
+ phases[phase] = now - phaseStartedAt;
100410
+ phaseStartedAt = now;
100411
+ };
100412
+ advance("captureMs");
99366
100413
  const fingerprint = screenshotFingerprint(shot.base64);
100414
+ let escalatedToGrid = false;
100415
+ if (screenMode === "vision" && !gridMode && performed) {
100416
+ if (isPointTap(performed) && prevFingerprint != null && fingerprint === prevFingerprint) {
100417
+ unchangedPointTaps += 1;
100418
+ } else {
100419
+ unchangedPointTaps = 0;
100420
+ }
100421
+ if (unchangedPointTaps >= GRID_ESCALATION_TAPS) {
100422
+ gridMode = true;
100423
+ escalatedToGrid = true;
100424
+ }
100425
+ }
100426
+ if (performed && performed.type !== "wait") {
100427
+ unchangedActions = prevFingerprint != null && fingerprint === prevFingerprint ? unchangedActions + 1 : 0;
100428
+ }
100429
+ const treeAssist = screenMode === "vision" && !gameApp && unchangedActions >= TREE_ASSIST_ACTIONS;
100430
+ if (performed?.type === "wait") {
100431
+ unchangedWaits = prevFingerprint != null && fingerprint === prevFingerprint ? unchangedWaits + 1 : 0;
100432
+ } else if (performed) {
100433
+ unchangedWaits = 0;
100434
+ }
100435
+ if (performed && isPointTap(performed)) {
100436
+ const point = { x: performed.x, y: performed.y };
100437
+ const unchanged = prevFingerprint != null && fingerprint === prevFingerprint;
100438
+ const sameSpot = lastTapPoint != null && Math.hypot(point.x - lastTapPoint.x, point.y - lastTapPoint.y) <= REPEAT_TAP_RADIUS;
100439
+ repeatTaps = unchanged ? sameSpot ? repeatTaps + 1 : 1 : 0;
100440
+ lastTapPoint = point;
100441
+ } else if (performed) {
100442
+ repeatTaps = 0;
100443
+ lastTapPoint = null;
100444
+ }
100445
+ let tree = omitsTree() && !treeAssist ? { snapshot: NO_TREE_SNAPSHOT, elements: [] } : await readCleanedTree(readScreen2, deps.session);
100446
+ advance("screenMs");
100447
+ const canvas = !omitsTree() && isCanvasScreen(tree.elements);
100448
+ if ((!omitsTree() || treeAssist) && isGameSurface(tree.elements)) {
100449
+ gridMode = true;
100450
+ tree = { snapshot: NO_TREE_SNAPSHOT, elements: [] };
100451
+ }
100452
+ const assisted = treeAssist && tree.elements.length > 0;
100453
+ const screenshotOnly = omitsTree() && !assisted;
100454
+ const gridded = gridMode && !assisted || canvas ? overlayCoordGrid(shot.base64) : null;
100455
+ const stallHint = unchangedWaits >= STUCK_WAITS ? stuckWaitHint(unchangedWaits) : repeatTaps >= REPEAT_TAPS && lastTapPoint ? repeatTapHint(lastTapPoint.x, lastTapPoint.y, repeatTaps, assisted ? "tree" : gridMode ? "grid" : "vision") : undefined;
100456
+ const visionBase64 = gridded ?? shot.base64;
100457
+ const image = await prepareVisionImage(visionBase64);
100458
+ advance("prepareMs");
99367
100459
  const lastAction = recentActions.at(-1);
99368
100460
  const lastSwipeMovedScreen = lastAction?.type === "swipe" && prevFingerprint != null && fingerprint !== prevFingerprint;
99369
100461
  if (deps.isAborted()) {
@@ -99373,69 +100465,106 @@ async function executeAgentCase(deps) {
99373
100465
  }
99374
100466
  const decideInput = {
99375
100467
  flow: instruction,
99376
- imageBase64: shot.base64,
100468
+ imageBase64: visionBase64,
100469
+ image,
99377
100470
  recentActions,
99378
100471
  screenSnapshot: tree.snapshot,
100472
+ lastError: stallHint,
99379
100473
  lastSwipeMovedScreen,
99380
100474
  completedInstructions,
99381
100475
  instructionOrdinal: instructionIndex + 1,
99382
- instructionCount: instructionQueue.length
100476
+ instructionCount: instructionQueue.length,
100477
+ screenElements: tree.elements,
100478
+ coordGrid: gridded != null,
100479
+ screenshotOnly,
100480
+ onDecideRetry: () => {
100481
+ phases.decideRetries += 1;
100482
+ }
100483
+ };
100484
+ lastVision = {
100485
+ imageBase64: visionBase64,
100486
+ image,
100487
+ screenSnapshot: tree.snapshot,
100488
+ coordGrid: gridded != null,
100489
+ screenshotOnly
99383
100490
  };
99384
100491
  let decision = await decideOnce(decideInput);
99385
100492
  prevFingerprint = fingerprint;
99386
- const latencyMs = clock.now() - shotStarted;
100493
+ let snappedTo = null;
100494
+ if (assisted) {
100495
+ const snapped = snapTapToElement(decision, tree.elements);
100496
+ decision = snapped.decision;
100497
+ snappedTo = snapped.element;
100498
+ }
100499
+ let verdict = await verdictForDecision(decision, decideInput);
100500
+ const guarded = continueScrollingInsteadOfComplete({
100501
+ decision: {
100502
+ type: "verify",
100503
+ reason: verdict.reason,
100504
+ thoughts: verdict.thoughts
100505
+ },
100506
+ instructions: instruction.instructions,
100507
+ expectedResult: instruction.expectedResult,
100508
+ recentActions,
100509
+ lastSwipeMovedScreen
100510
+ });
100511
+ if (verdict.type === "verify" && guarded.type !== "verify" && guarded.type !== "done") {
100512
+ verdict = {
100513
+ type: "continue",
100514
+ reason: guarded.reason,
100515
+ thoughts: guarded.thoughts
100516
+ };
100517
+ if (!isDeviceDecision(decision))
100518
+ decision = guarded;
100519
+ }
100520
+ advance("decideMs");
100521
+ const latencyMs = phaseStartedAt - shotStarted;
100522
+ inFlightLatencyMs = latencyMs;
99387
100523
  if (deps.isAborted()) {
99388
100524
  caseStatus = "cancelled";
99389
100525
  instructionDone = true;
99390
100526
  break;
99391
100527
  }
99392
- const applyWithRetry = async () => {
99393
- try {
99394
- return await applyDecision(decision);
99395
- } catch (error40) {
99396
- if (!isRetriableActionError(error40) || deps.isAborted())
99397
- throw error40;
99398
- const failed = decision;
99399
- decision = await decideOnce({
99400
- ...decideInput,
99401
- recentActions: [...recentActions, failed],
99402
- lastError: error40 instanceof Error ? error40.message : String(error40)
99403
- });
99404
- const retryCommand = commandForDecision(decision, deps.defaultAppId);
99405
- await setCurrentCommand(retryCommand);
99406
- return await applyDecision(decision);
99407
- }
99408
- };
99409
- const recordStep = async (command, outcome2) => {
99410
- recentActions.push(decision);
99411
- recordedDecisions.push(decision);
99412
- await deps.appendStep({
99413
- idx: stepIdx,
99414
- action: decision,
99415
- screenshotUri: shot.path,
99416
- ok: outcome2 !== "fail",
99417
- latencyMs,
99418
- detail: decision.type === "wait" ? decision.reason ?? `wait ${Math.min(3000, Math.max(500, decision.ms ?? 1500))}ms` : decision.reason ?? (outcome2 === "fail" ? "Agent failed the step" : null),
99419
- command
99420
- });
99421
- };
99422
- const initialCommand = commandForDecision(decision, deps.defaultAppId);
99423
- const outcome = initialCommand ? await withCurrentCommand(setCurrentCommand, initialCommand, async () => {
99424
- const result = await applyWithRetry();
99425
- await recordStep(commandForDecision(decision, deps.defaultAppId), result);
99426
- return result;
99427
- }) : await (async () => {
99428
- const result = await applyWithRetry();
99429
- await recordStep(null, result);
99430
- return result;
99431
- })();
100528
+ const outcome = verdict.type === "fail" ? "fail" : verdict.type === "verify" ? "done" : "continue";
100529
+ const command = commandForDecision(performed ?? decision, deps.defaultAppId);
100530
+ recentActions.push(decision);
100531
+ recordedDecisions.push(decision);
100532
+ await deps.appendStep({
100533
+ idx: stepIdx,
100534
+ action: {
100535
+ ...decision,
100536
+ ...escalatedToGrid ? { escalatedToGrid: true } : {},
100537
+ ...assisted ? { treeAssist: true } : {},
100538
+ ...snappedTo ? { snappedTo: snappedTo.id?.trim() || snappedTo.label } : {},
100539
+ cycle: {
100540
+ action: performed,
100541
+ decision,
100542
+ verify: {
100543
+ type: verdict.type,
100544
+ reason: verdict.reason,
100545
+ thoughts: verdict.thoughts
100546
+ }
100547
+ }
100548
+ },
100549
+ screenshotUri: shot.path,
100550
+ ok: outcome !== "fail",
100551
+ latencyMs,
100552
+ phases,
100553
+ detail: verdict.reason || (decision.type === "wait" ? decision.reason ?? `wait ${Math.min(3000, Math.max(500, decision.ms ?? 1500))}ms` : decision.reason ?? (outcome === "fail" ? "Agent failed the step" : null)),
100554
+ command
100555
+ });
99432
100556
  if (outcome === "done") {
99433
100557
  instructionDone = true;
99434
100558
  completedInstructions.push(instruction.instructions);
100559
+ pending = null;
99435
100560
  } else if (outcome === "fail") {
99436
100561
  caseStatus = "errored";
99437
- caseError = decision.reason ?? "Agent marked the instruction as failed";
100562
+ caseError = verdict.reason || decision.reason || "Agent marked the instruction as failed";
99438
100563
  instructionDone = true;
100564
+ pending = null;
100565
+ } else {
100566
+ pending = isDeviceDecision(decision) ? decision : null;
100567
+ pendingElements = tree.elements;
99439
100568
  }
99440
100569
  stepIdx += 1;
99441
100570
  }
@@ -99462,7 +100591,8 @@ async function executeAgentCase(deps) {
99462
100591
  },
99463
100592
  screenshotUri: lastScreenshotUri,
99464
100593
  ok: false,
99465
- latencyMs: 0,
100594
+ latencyMs: inFlightLatencyMs,
100595
+ phases: inFlightPhases,
99466
100596
  detail: caseError,
99467
100597
  command: null
99468
100598
  });
@@ -99504,6 +100634,15 @@ function parseAction(raw2) {
99504
100634
  return {};
99505
100635
  }
99506
100636
  }
100637
+ function parsePhases(raw2) {
100638
+ if (!raw2)
100639
+ return null;
100640
+ try {
100641
+ return stepPhasesSchema.parse(JSON.parse(raw2));
100642
+ } catch {
100643
+ return null;
100644
+ }
100645
+ }
99507
100646
  function isAborted2(runId) {
99508
100647
  return runControls.get(runId)?.aborted === true;
99509
100648
  }
@@ -99523,6 +100662,9 @@ function parseRunExecutionMode(value) {
99523
100662
  return value;
99524
100663
  return "auto";
99525
100664
  }
100665
+ function parseRunScreenMode(value) {
100666
+ return value === "vision" ? "vision" : "tree";
100667
+ }
99526
100668
  function resolveCaseExecutionMode(runMode, hasScript) {
99527
100669
  if (runMode === "agent")
99528
100670
  return "agent";
@@ -99547,6 +100689,7 @@ async function loadRun(runId) {
99547
100689
  screenshotUri: step.screenshotUri,
99548
100690
  ok: step.ok === 1,
99549
100691
  latencyMs: step.latencyMs,
100692
+ phases: parsePhases(step.phasesJson),
99550
100693
  detail: step.detail,
99551
100694
  command: step.command ?? null,
99552
100695
  createdAt: step.createdAt
@@ -99573,6 +100716,7 @@ async function loadRun(runId) {
99573
100716
  buildId: runRow.buildId,
99574
100717
  status: runRow.status,
99575
100718
  executionMode: parseRunExecutionMode(runRow.executionMode),
100719
+ screenMode: parseRunScreenMode(runRow.screenMode),
99576
100720
  error: runRow.error,
99577
100721
  createdAt: runRow.createdAt,
99578
100722
  startedAt: runRow.startedAt,
@@ -99590,6 +100734,7 @@ async function appendStep(input) {
99590
100734
  screenshotUri: input.screenshotUri,
99591
100735
  ok: input.ok ? 1 : 0,
99592
100736
  latencyMs: Math.max(0, Math.round(input.latencyMs)),
100737
+ phasesJson: input.phases ? JSON.stringify(input.phases) : null,
99593
100738
  detail: input.detail,
99594
100739
  command: input.command,
99595
100740
  createdAt: Date.now()
@@ -99656,6 +100801,7 @@ async function executeAgentCase2(input) {
99656
100801
  return executeAgentCase({
99657
100802
  catalogCase,
99658
100803
  appContext: input.appContext,
100804
+ appKnowledge: input.appKnowledge,
99659
100805
  auth: input.auth,
99660
100806
  session: input.session,
99661
100807
  isAborted: () => isAborted2(input.runId),
@@ -99669,6 +100815,7 @@ async function executeAgentCase2(input) {
99669
100815
  await setCurrentCommand(input.runTestId, command);
99670
100816
  },
99671
100817
  decide: decideNextAction,
100818
+ screenMode: input.screenMode,
99672
100819
  clock: { sleep, now: () => Date.now() },
99673
100820
  defaultAppId: input.defaultAppId
99674
100821
  });
@@ -99710,8 +100857,10 @@ async function executeCase(input) {
99710
100857
  runTestId: input.runTestId,
99711
100858
  caseId: input.caseId,
99712
100859
  appContext: input.appContext,
100860
+ appKnowledge: input.appKnowledge,
99713
100861
  session: input.session,
99714
100862
  auth: input.auth,
100863
+ screenMode: input.screenMode,
99715
100864
  defaultAppId: input.defaultAppId
99716
100865
  });
99717
100866
  caseStatus = result.status;
@@ -99752,6 +100901,7 @@ async function executeRun(runId) {
99752
100901
  }
99753
100902
  const startedAt = Date.now();
99754
100903
  await db.update(runs).set({ status: "running", startedAt, error: null }).where(eq(runs.id, runId));
100904
+ pruneOldRunScreenshots();
99755
100905
  if (isAborted2(runId)) {
99756
100906
  await persistCancelled(runId);
99757
100907
  return;
@@ -99822,9 +100972,11 @@ async function executeRun(runId) {
99822
100972
  runTestId: test.id,
99823
100973
  caseId: test.caseId,
99824
100974
  appContext: app.context,
100975
+ appKnowledge: app.knowledge,
99825
100976
  session,
99826
100977
  auth,
99827
100978
  caseMode,
100979
+ screenMode: run.screenMode ?? "tree",
99828
100980
  defaultAppId: run.platform === "ios" ? app.iosBundleId || undefined : app.androidApplicationId || undefined
99829
100981
  });
99830
100982
  if (status === "cancelled" || isAborted2(runId)) {
@@ -99889,6 +101041,7 @@ async function createRun(input) {
99889
101041
  throw new RunValidationError("App not found");
99890
101042
  }
99891
101043
  const executionMode = input.executionMode ?? "auto";
101044
+ const screenMode = input.screenMode ?? "vision";
99892
101045
  const catalogCases = [];
99893
101046
  for (const caseId of uniqueCaseIds) {
99894
101047
  const catalogCase = await getCase(caseId);
@@ -99930,6 +101083,7 @@ async function createRun(input) {
99930
101083
  buildId,
99931
101084
  status: "queued",
99932
101085
  executionMode,
101086
+ screenMode,
99933
101087
  error: null,
99934
101088
  createdAt: now,
99935
101089
  startedAt: null,
@@ -99999,6 +101153,7 @@ async function listRuns(appId) {
99999
101153
  buildId: runRow.buildId,
100000
101154
  status: runRow.status,
100001
101155
  executionMode: parseRunExecutionMode(runRow.executionMode),
101156
+ screenMode: parseRunScreenMode(runRow.screenMode),
100002
101157
  error: runRow.error,
100003
101158
  createdAt: runRow.createdAt,
100004
101159
  startedAt: runRow.startedAt,
@@ -100233,8 +101388,84 @@ function createServersRoutes() {
100233
101388
  }
100234
101389
 
100235
101390
  // src/interfaces/http/session.ts
100236
- import { mkdir as mkdir4, rename as rename2 } from "fs/promises";
101391
+ import { mkdir as mkdir5, rename as rename2 } from "fs/promises";
100237
101392
  import { dirname as dirname4 } from "path";
101393
+
101394
+ // src/domains/devices/action-result.ts
101395
+ import { mkdir as mkdir4 } from "fs/promises";
101396
+ import { join as join16 } from "path";
101397
+ var DEFAULT_SETTLE_CAP_MS = 1500;
101398
+ var SETTLE_POLL_MS = 250;
101399
+ var realClock = { now: () => Date.now(), sleep: (ms) => Bun.sleep(ms) };
101400
+ function frameHash(base643) {
101401
+ return String(Bun.hash(base643));
101402
+ }
101403
+ async function settleScreen(capture, options = {}) {
101404
+ const capMs = options.capMs ?? DEFAULT_SETTLE_CAP_MS;
101405
+ const pollMs = options.pollMs ?? SETTLE_POLL_MS;
101406
+ const clock = options.clock ?? realClock;
101407
+ const started = clock.now();
101408
+ let latest;
101409
+ let previousHash;
101410
+ for (;; ) {
101411
+ const elapsed = clock.now() - started;
101412
+ if (latest !== undefined && elapsed >= capMs) {
101413
+ return { base64: latest, settled: false, waitedMs: elapsed };
101414
+ }
101415
+ await clock.sleep(Math.max(0, Math.min(pollMs, capMs - elapsed)));
101416
+ latest = (await capture()).base64;
101417
+ const hash = frameHash(latest);
101418
+ if (previousHash === hash) {
101419
+ return { base64: latest, settled: true, waitedMs: clock.now() - started };
101420
+ }
101421
+ previousHash = hash;
101422
+ }
101423
+ }
101424
+ function actionMarks(body, resolved) {
101425
+ const x = resolved?.x ?? body.x;
101426
+ const y = resolved?.y ?? body.y;
101427
+ if (x == null || y == null)
101428
+ return [];
101429
+ if ((body.kind === "swipe" || body.kind === "drag") && body.x2 != null && body.y2 != null) {
101430
+ return [{ kind: "path", x, y, x2: body.x2, y2: body.y2 }];
101431
+ }
101432
+ if (body.kind === "tap" || body.kind === "input")
101433
+ return [{ kind: "tap", x, y }];
101434
+ return [];
101435
+ }
101436
+ async function writePng(dir, base643, label) {
101437
+ await mkdir4(dir, { recursive: true });
101438
+ const path = join16(dir, `${label}_${Date.now()}_${crypto.randomUUID()}.png`);
101439
+ await Bun.write(path, Uint8Array.from(Buffer.from(base643, "base64")));
101440
+ return path;
101441
+ }
101442
+ async function performActionWithScreenshot(session, body, deps = {}) {
101443
+ const perform = deps.perform ?? performAction;
101444
+ const before = await session.captureFrame().then((frame) => frame.base64, () => {
101445
+ return;
101446
+ });
101447
+ const response = await perform(session, body);
101448
+ const settle = await settleScreen(() => session.captureFrame(), {
101449
+ capMs: body.settleMs,
101450
+ pollMs: deps.pollMs,
101451
+ clock: deps.clock
101452
+ });
101453
+ const dir = deps.dir ?? SCREENSHOT_DIR;
101454
+ const path = await writePng(dir, settle.base64, "result");
101455
+ const marks = actionMarks(body, response.resolved);
101456
+ const annotated = overlayActionMarks(settle.base64, marks);
101457
+ const annotatedPath = annotated ? await writePng(dir, annotated, "result_marked") : undefined;
101458
+ const screenshot = {
101459
+ path,
101460
+ ...annotatedPath ? { annotatedPath } : {},
101461
+ settled: settle.settled,
101462
+ waitedMs: settle.waitedMs,
101463
+ changed: settle.settled && before !== undefined ? frameHash(before) !== frameHash(settle.base64) : null
101464
+ };
101465
+ return { ...response, screenshot };
101466
+ }
101467
+
101468
+ // src/interfaces/http/session.ts
100238
101469
  function sessionErrorResponse(error40) {
100239
101470
  const message = error40 instanceof Error ? error40.message : String(error40);
100240
101471
  if (error40 instanceof SessionBusyError) {
@@ -100333,7 +101564,7 @@ function createSessionRoutes() {
100333
101564
  const shot = await session.screenshot();
100334
101565
  let path = shot.path;
100335
101566
  if (parsed.data.path) {
100336
- await mkdir4(dirname4(parsed.data.path), { recursive: true });
101567
+ await mkdir5(dirname4(parsed.data.path), { recursive: true });
100337
101568
  await rename2(shot.path, parsed.data.path);
100338
101569
  path = parsed.data.path;
100339
101570
  }
@@ -100427,7 +101658,7 @@ function createSessionRoutes() {
100427
101658
  error: "A run is using this device session. Cancel the run to interact manually."
100428
101659
  }, 409);
100429
101660
  }
100430
- const result = await performAction(session, parsed.data);
101661
+ const result = parsed.data.screenshot ? await performActionWithScreenshot(session, parsed.data) : await performAction(session, parsed.data);
100431
101662
  return c.json(actionResponseSchema.parse(result));
100432
101663
  } catch (error40) {
100433
101664
  const gone = sessionErrorResponse(error40);