@yoqa/runner 0.3.27 → 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 +1382 -163
  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.27",
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
@@ -17626,10 +17653,60 @@ async function typeText(browser, text2) {
17626
17653
  return "w3c";
17627
17654
  }
17628
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
+
17629
17707
  // src/domains/devices/session.ts
17630
- var YOQA_ROOT4 = join7(homedir6(), ".yoqa");
17708
+ var YOQA_ROOT4 = join8(homedir7(), ".yoqa");
17631
17709
  var DEFAULT_MJPEG_PORT = Number(process.env.YOQA_MJPEG_PORT ?? "9100");
17632
- var SCREENSHOT_DIR = join7(YOQA_ROOT4, "runs", "screenshots");
17633
17710
  var MJPEG_SETTINGS_BASE = {
17634
17711
  mjpegServerScreenshotQuality: 35,
17635
17712
  mjpegScalingFactor: 50
@@ -17747,7 +17824,7 @@ function looksLikePhysicalIosUdid(udid) {
17747
17824
  }
17748
17825
  var PHYSICAL_IOS_SESSION_TIMEOUT_MS = 60000;
17749
17826
  var SIMULATOR_WDA_SESSION_TIMEOUT_MS = 600000;
17750
- var SIMULATOR_WDA_DERIVED_DATA = join7(YOQA_ROOT4, "wda-sim");
17827
+ var SIMULATOR_WDA_DERIVED_DATA = join8(YOQA_ROOT4, "wda-sim");
17751
17828
  function iosSessionCreateTimeoutMs(deviceId) {
17752
17829
  return looksLikePhysicalIosUdid(deviceId) ? PHYSICAL_IOS_SESSION_TIMEOUT_MS : SIMULATOR_WDA_SESSION_TIMEOUT_MS;
17753
17830
  }
@@ -18092,7 +18169,7 @@ async function createDeviceSession(options) {
18092
18169
  const screenshot = async () => {
18093
18170
  await mkdir3(SCREENSHOT_DIR, { recursive: true });
18094
18171
  const frame = await captureFrame();
18095
- const path = join7(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
18172
+ const path = join8(SCREENSHOT_DIR, `shot_${Date.now()}_${crypto.randomUUID()}.png`);
18096
18173
  await Bun.write(path, Uint8Array.from(Buffer.from(frame.base64, "base64")));
18097
18174
  return { path, base64: frame.base64 };
18098
18175
  };
@@ -24287,6 +24364,7 @@ var catalogAppSchema = exports_external.object({
24287
24364
  name: exports_external.string().min(1),
24288
24365
  prefix: exports_external.string().min(1),
24289
24366
  context: exports_external.string(),
24367
+ knowledge: exports_external.string(),
24290
24368
  iosBundleId: exports_external.string(),
24291
24369
  iosAppStoreId: exports_external.string(),
24292
24370
  androidApplicationId: exports_external.string(),
@@ -24302,6 +24380,7 @@ var updateAppRequestSchema = exports_external.object({
24302
24380
  name: exports_external.string().min(1).optional(),
24303
24381
  prefix: exports_external.string().min(1).optional(),
24304
24382
  context: exports_external.string().optional(),
24383
+ knowledge: exports_external.string().optional(),
24305
24384
  iosBundleId: exports_external.string().optional(),
24306
24385
  iosAppStoreId: exports_external.string().optional(),
24307
24386
  androidApplicationId: exports_external.string().optional(),
@@ -24615,7 +24694,8 @@ var probeProviderResponseSchema = exports_external.object({
24615
24694
  var providerModelSchema = exports_external.object({
24616
24695
  id: exports_external.string().min(1),
24617
24696
  name: exports_external.string().min(1),
24618
- provider: exports_external.string().min(1).optional()
24697
+ provider: exports_external.string().min(1).optional(),
24698
+ vision: exports_external.boolean().optional()
24619
24699
  });
24620
24700
  var listProviderModelsResponseSchema = exports_external.object({
24621
24701
  models: exports_external.array(providerModelSchema),
@@ -24644,6 +24724,7 @@ var runExecutionModeSchema = exports_external.union([
24644
24724
  exports_external.literal("script"),
24645
24725
  exports_external.literal("agent")
24646
24726
  ]);
24727
+ var runScreenModeSchema = exports_external.union([exports_external.literal("vision"), exports_external.literal("tree")]);
24647
24728
  var createRunRequestSchema = exports_external.object({
24648
24729
  appId: exports_external.string().min(1),
24649
24730
  caseIds: exports_external.array(exports_external.string().min(1)).min(1),
@@ -24651,7 +24732,17 @@ var createRunRequestSchema = exports_external.object({
24651
24732
  platform: devicePlatformSchema,
24652
24733
  buildId: exports_external.string().min(1).optional(),
24653
24734
  buildPath: exports_external.string().min(1).optional(),
24654
- 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()
24655
24746
  });
24656
24747
  var runStepSchema = exports_external.object({
24657
24748
  id: exports_external.string().min(1),
@@ -24661,6 +24752,7 @@ var runStepSchema = exports_external.object({
24661
24752
  screenshotUri: exports_external.string().nullable(),
24662
24753
  ok: exports_external.boolean(),
24663
24754
  latencyMs: exports_external.number().nonnegative(),
24755
+ phases: stepPhasesSchema.nullable().optional(),
24664
24756
  detail: exports_external.string().nullable(),
24665
24757
  command: exports_external.string().nullable().optional(),
24666
24758
  createdAt: exports_external.number().int().nonnegative()
@@ -24685,6 +24777,7 @@ var runSchema = exports_external.object({
24685
24777
  buildId: exports_external.string().nullable(),
24686
24778
  status: runStatusSchema,
24687
24779
  executionMode: runExecutionModeSchema,
24780
+ screenMode: runScreenModeSchema.optional(),
24688
24781
  error: exports_external.string().nullable(),
24689
24782
  createdAt: exports_external.number().int().nonnegative(),
24690
24783
  startedAt: exports_external.number().int().nonnegative().nullable(),
@@ -24771,7 +24864,16 @@ var actionRequestSchema = exports_external.object({
24771
24864
  appId: exports_external.string().optional(),
24772
24865
  url: exports_external.string().optional(),
24773
24866
  alertAction: exports_external.union([exports_external.literal("accept"), exports_external.literal("dismiss")]).optional(),
24774
- 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()
24775
24877
  });
24776
24878
  var actionResponseSchema = exports_external.object({
24777
24879
  ok: exports_external.literal(true),
@@ -24779,7 +24881,8 @@ var actionResponseSchema = exports_external.object({
24779
24881
  resolved: exports_external.object({
24780
24882
  x: exports_external.number().optional(),
24781
24883
  y: exports_external.number().optional()
24782
- }).optional()
24884
+ }).optional(),
24885
+ screenshot: actionResultScreenshotSchema.optional()
24783
24886
  });
24784
24887
  var yoqaStatusResponseSchema = exports_external.object({
24785
24888
  runner: exports_external.object({
@@ -25353,9 +25456,9 @@ function normalizeVisionJson(value) {
25353
25456
 
25354
25457
  // src/domains/providers/vision-model.ts
25355
25458
  import { mkdtemp as mkdtemp3, readFile as readFile2, rm as rm3, writeFile as writeFile3 } from "fs/promises";
25356
- import { homedir as homedir7 } from "os";
25459
+ import { homedir as homedir8 } from "os";
25357
25460
  import { tmpdir as tmpdir3 } from "os";
25358
- import { join as join8 } from "path";
25461
+ import { join as join9 } from "path";
25359
25462
 
25360
25463
  // ../../node_modules/@ai-sdk/gateway/node_modules/@ai-sdk/provider/dist/index.js
25361
25464
  var marker = "vercel.ai.error";
@@ -48027,9 +48130,9 @@ async function prepareVisionImage(pngBase64) {
48027
48130
  if (process.platform !== "darwin") {
48028
48131
  return { base64: pngBase64, mediaType: "image/png" };
48029
48132
  }
48030
- const dir = await mkdtemp3(join8(tmpdir3(), "yoqa-vision-"));
48031
- const inPath = join8(dir, "shot.png");
48032
- 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");
48033
48136
  try {
48034
48137
  await writeFile3(inPath, new Uint8Array(bytes));
48035
48138
  const proc = Bun.spawn([
@@ -48083,6 +48186,9 @@ function formatProviderHttpError(label, status, body) {
48083
48186
  if (label === "OpenCode" && status >= 500 && (body.includes("Internal server error") || body.includes('"type":"error"'))) {
48084
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).`;
48085
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
+ }
48086
48192
  return `${label} request failed (${status}): ${body.slice(0, 400)}`;
48087
48193
  }
48088
48194
  function isJsonRepairableError(error40) {
@@ -48137,7 +48243,7 @@ async function completeWithAiSdk(input) {
48137
48243
  model: input.model,
48138
48244
  schema: input.schema,
48139
48245
  system: input.system,
48140
- maxOutputTokens: VISION_MAX_TOKENS,
48246
+ maxOutputTokens: input.maxOutputTokens ?? VISION_MAX_TOKENS,
48141
48247
  experimental_repairText: async ({ text: text3 }) => salvageAgentJsonText(text3),
48142
48248
  messages: [
48143
48249
  {
@@ -48175,6 +48281,7 @@ async function completeWithAiSdk(input) {
48175
48281
  if (!(error40 instanceof AgentProviderError) || !isJsonRepairableError(error40)) {
48176
48282
  throw error40;
48177
48283
  }
48284
+ input.onDecideRetry?.();
48178
48285
  return run(`${input.prompt}
48179
48286
 
48180
48287
  ${JSON_REPAIR_PROMPT}`);
@@ -48184,7 +48291,7 @@ function createSdkVisionPort(opts) {
48184
48291
  return {
48185
48292
  async completeObject(input) {
48186
48293
  const modelId = input.auth.defaultModel?.trim() || opts.defaultModel;
48187
- const image = await prepareVisionImage(input.imageBase64);
48294
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
48188
48295
  const model = await opts.createModel(input.auth, modelId);
48189
48296
  return completeWithAiSdk({
48190
48297
  label: opts.label,
@@ -48192,7 +48299,9 @@ function createSdkVisionPort(opts) {
48192
48299
  schema: input.schema,
48193
48300
  system: input.system,
48194
48301
  prompt: input.prompt,
48195
- image
48302
+ image,
48303
+ onDecideRetry: input.onDecideRetry,
48304
+ maxOutputTokens: opts.maxOutputTokens
48196
48305
  });
48197
48306
  }
48198
48307
  };
@@ -48204,8 +48313,8 @@ function openCodeAuthPaths() {
48204
48313
  const paths = [];
48205
48314
  const xdg = process.env.XDG_DATA_HOME?.trim();
48206
48315
  if (xdg)
48207
- paths.push(join8(xdg, "opencode", "auth.json"));
48208
- 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"));
48209
48318
  return paths;
48210
48319
  }
48211
48320
  function apiKeyFromOpenCodeAuthEntry(value) {
@@ -48298,6 +48407,64 @@ function resolveGoogleKey(auth) {
48298
48407
  function resolveVertexApiKey(auth) {
48299
48408
  return auth.apiKey?.trim() || auth.env.GOOGLE_VERTEX_API_KEY?.trim() || process.env.GOOGLE_VERTEX_API_KEY?.trim() || null;
48300
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
+ }
48301
48468
  function withOpenCodeRequestHooks(opts) {
48302
48469
  const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
48303
48470
  return async (input, init) => {
@@ -57358,8 +57525,8 @@ var anthropic = createAnthropic();
57358
57525
  // src/domains/providers/drivers/probe.ts
57359
57526
  import { constants } from "fs";
57360
57527
  import { access } from "fs/promises";
57361
- import { homedir as homedir8 } from "os";
57362
- import { isAbsolute, join as join9 } from "path";
57528
+ import { homedir as homedir9 } from "os";
57529
+ import { isAbsolute, join as join10 } from "path";
57363
57530
  async function pathExists3(path) {
57364
57531
  try {
57365
57532
  await access(path, constants.F_OK);
@@ -57384,13 +57551,13 @@ async function which2(binary) {
57384
57551
  `)[0]?.trim();
57385
57552
  return resolved || null;
57386
57553
  }
57387
- function knownHomeBinCandidates(binaryName, home = homedir8()) {
57554
+ function knownHomeBinCandidates(binaryName, home = homedir9()) {
57388
57555
  return [
57389
- join9(home, ".opencode", "bin", binaryName),
57390
- join9(home, ".grok", "bin", binaryName),
57391
- join9(home, ".antigravity", "antigravity", "bin", binaryName),
57392
- join9(home, ".local", "bin", binaryName),
57393
- 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),
57394
57561
  `/opt/homebrew/bin/${binaryName}`,
57395
57562
  `/usr/local/bin/${binaryName}`
57396
57563
  ];
@@ -57623,7 +57790,7 @@ var anthropicDriver = {
57623
57790
  // src/domains/providers/drivers/antigravity-vision.ts
57624
57791
  import { mkdtemp as mkdtemp4, rm as rm4, writeFile as writeFile4 } from "fs/promises";
57625
57792
  import { tmpdir as tmpdir4 } from "os";
57626
- import { join as join10 } from "path";
57793
+ import { join as join11 } from "path";
57627
57794
 
57628
57795
  // ../../node_modules/@ai-sdk/google/node_modules/@ai-sdk/provider/dist/index.js
57629
57796
  var marker29 = "vercel.ai.error";
@@ -67096,11 +67263,11 @@ async function completeWithAgyCli(input, repairHint) {
67096
67263
  if (!resolved.path) {
67097
67264
  throw new AgentProviderError(resolved.detail || "Antigravity CLI (`agy`) not found. Install it or paste a Google AI Studio API key.");
67098
67265
  }
67099
- const image = await prepareVisionImage(input.imageBase64);
67266
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
67100
67267
  const model = input.auth.defaultModel?.trim() || ANTIGRAVITY_DEFAULT_VISION_MODEL;
67101
67268
  const ext = image.mediaType === "image/jpeg" ? "jpg" : "png";
67102
- const dir = await mkdtemp4(join10(tmpdir4(), "yoqa-agy-"));
67103
- const shotPath = join10(dir, `shot.${ext}`);
67269
+ const dir = await mkdtemp4(join11(tmpdir4(), "yoqa-agy-"));
67270
+ const shotPath = join11(dir, `shot.${ext}`);
67104
67271
  try {
67105
67272
  await writeFile4(shotPath, new Uint8Array(Buffer.from(image.base64, "base64")));
67106
67273
  const userText = [
@@ -67151,7 +67318,7 @@ var antigravityVision = {
67151
67318
  if (apiKey) {
67152
67319
  const requested = input.auth.defaultModel?.trim() || ANTIGRAVITY_DEFAULT_VISION_MODEL;
67153
67320
  const googleModelId = requested.startsWith("gemini-1.") || requested.startsWith("gemini-2.") ? requested : "gemini-2.5-flash";
67154
- const image = await prepareVisionImage(input.imageBase64);
67321
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
67155
67322
  const provider = createGoogle({ apiKey });
67156
67323
  return completeWithAiSdk({
67157
67324
  label: "Antigravity",
@@ -67159,7 +67326,8 @@ var antigravityVision = {
67159
67326
  schema: input.schema,
67160
67327
  system: input.system,
67161
67328
  prompt: input.prompt,
67162
- image
67329
+ image,
67330
+ onDecideRetry: input.onDecideRetry
67163
67331
  });
67164
67332
  }
67165
67333
  try {
@@ -67168,6 +67336,7 @@ var antigravityVision = {
67168
67336
  if (!(error40 instanceof AgentProviderError) || !isJsonRepairableError(error40)) {
67169
67337
  throw error40;
67170
67338
  }
67339
+ input.onDecideRetry?.();
67171
67340
  return completeWithAgyCli(input, JSON_REPAIR_PROMPT2);
67172
67341
  }
67173
67342
  }
@@ -75614,7 +75783,7 @@ ${result.stderr}`.toLowerCase();
75614
75783
  // src/domains/providers/drivers/cursor-vision.ts
75615
75784
  import { mkdtemp as mkdtemp5, rm as rm5, writeFile as writeFile5 } from "fs/promises";
75616
75785
  import { tmpdir as tmpdir5 } from "os";
75617
- import { join as join11 } from "path";
75786
+ import { join as join12 } from "path";
75618
75787
  var CURSOR_DEFAULT_VISION_MODEL = "auto";
75619
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).";
75620
75789
  function resolveCursorApiKey(auth) {
@@ -75627,8 +75796,8 @@ async function invokeCursorPrint(input) {
75627
75796
  }
75628
75797
  const model = input.auth.defaultModel?.trim() || CURSOR_DEFAULT_VISION_MODEL;
75629
75798
  const ext = input.mediaType === "image/jpeg" ? "jpg" : "png";
75630
- const dir = await mkdtemp5(join11(tmpdir5(), "yoqa-cursor-"));
75631
- const shotPath = join11(dir, `shot.${ext}`);
75799
+ const dir = await mkdtemp5(join12(tmpdir5(), "yoqa-cursor-"));
75800
+ const shotPath = join12(dir, `shot.${ext}`);
75632
75801
  const apiKey = resolveCursorApiKey(input.auth);
75633
75802
  try {
75634
75803
  await writeFile5(shotPath, new Uint8Array(Buffer.from(input.imageBase64, "base64")));
@@ -75671,7 +75840,7 @@ ${result.stderr}`.trim();
75671
75840
  }
75672
75841
  }
75673
75842
  async function completeOnce(input, repairHint) {
75674
- const image = await prepareVisionImage(input.imageBase64);
75843
+ const image = input.image ?? await prepareVisionImage(input.imageBase64);
75675
75844
  const ext = image.mediaType === "image/jpeg" ? "jpg" : "png";
75676
75845
  const shotName = `shot.${ext}`;
75677
75846
  const userText = [
@@ -75710,6 +75879,7 @@ var cursorVision = {
75710
75879
  if (!(error40 instanceof AgentProviderError) || !isJsonRepairableError(error40)) {
75711
75880
  throw error40;
75712
75881
  }
75882
+ input.onDecideRetry?.();
75713
75883
  return completeOnce(input, JSON_REPAIR_PROMPT3);
75714
75884
  }
75715
75885
  }
@@ -95601,6 +95771,18 @@ var groq = createGroq();
95601
95771
 
95602
95772
  // src/domains/providers/drivers/groq.ts
95603
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
+ }
95604
95786
  var groqDriver = {
95605
95787
  kind: "groq",
95606
95788
  label: "Groq",
@@ -95613,13 +95795,14 @@ var groqDriver = {
95613
95795
  vision: createSdkVisionPort({
95614
95796
  label: "Groq",
95615
95797
  defaultModel: "meta-llama/llama-4-scout-17b-16e-instruct",
95798
+ maxOutputTokens: 2048,
95616
95799
  createModel: (auth, modelId) => {
95617
95800
  const apiKey = resolveGroqKey(auth);
95618
95801
  if (!apiKey) {
95619
95802
  throw new AgentProviderError("Groq provider has no API key");
95620
95803
  }
95621
95804
  const baseURL = auth.baseUrl?.trim().replace(/\/$/, "") || DEFAULT_BASE2;
95622
- return createGroq({ apiKey, baseURL })(modelId);
95805
+ return createGroq({ apiKey, baseURL, fetch: withGroqRequestHooks({}) })(modelId);
95623
95806
  }
95624
95807
  }),
95625
95808
  async probe() {
@@ -95660,7 +95843,10 @@ var groqDriver = {
95660
95843
  label: "Groq"
95661
95844
  });
95662
95845
  return {
95663
- 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
+ }),
95664
95850
  message: result.ok ? `${result.models?.length ?? 0} models available` : result.message
95665
95851
  };
95666
95852
  }
@@ -95736,8 +95922,8 @@ var openaiDriver = {
95736
95922
  };
95737
95923
 
95738
95924
  // src/domains/providers/drivers/opencode-config.ts
95739
- import { homedir as homedir9 } from "os";
95740
- import { join as join12 } from "path";
95925
+ import { homedir as homedir10 } from "os";
95926
+ import { join as join13 } from "path";
95741
95927
  var OPENAI_COMPATIBLE_PACKAGES = new Set([
95742
95928
  "@ai-sdk/openai-compatible",
95743
95929
  "@ai-sdk/openai",
@@ -95765,8 +95951,8 @@ function openCodeConfigPaths() {
95765
95951
  const paths = [];
95766
95952
  const xdg = process.env.XDG_CONFIG_HOME?.trim();
95767
95953
  if (xdg)
95768
- paths.push(join12(xdg, "opencode", "opencode.json"));
95769
- 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"));
95770
95956
  return paths;
95771
95957
  }
95772
95958
  function parseOpenCodeCompatibleProvider(config2, providerId) {
@@ -96224,6 +96410,348 @@ async function completeVision(auth, input) {
96224
96410
  return resolveVision(auth).completeObject({ ...input, auth });
96225
96411
  }
96226
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
+
96227
96755
  // src/domains/runs/agent.ts
96228
96756
  var swipeDirectionSchema = exports_external.union([
96229
96757
  exports_external.literal("up"),
@@ -96254,6 +96782,14 @@ var agentDecisionSchema = exports_external.object({
96254
96782
  y: exports_external.number().optional(),
96255
96783
  x2: exports_external.number().optional(),
96256
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(),
96257
96793
  direction: swipeDirectionSchema.optional(),
96258
96794
  durationMs: exports_external.number().optional(),
96259
96795
  double: exports_external.boolean().optional(),
@@ -96316,12 +96852,174 @@ var MAX_SCREEN_SNAPSHOT_ROWS = 80;
96316
96852
  function padNorm(n) {
96317
96853
  return String(Math.round(n)).padStart(4, " ");
96318
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
+ }
96319
97013
  function formatScreenSnapshot(elements) {
96320
97014
  if (elements == null)
96321
97015
  return "(screen tree unavailable)";
96322
97016
  if (elements.length === 0)
96323
97017
  return "(empty tree)";
96324
- 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);
96325
97023
  const shown = ordered.slice(0, MAX_SCREEN_SNAPSHOT_ROWS);
96326
97024
  const lines = shown.map((element) => {
96327
97025
  const id = element.id?.trim() ? ` id=${element.id.trim()}` : "";
@@ -96501,13 +97199,44 @@ function prefersScreenshotTap(decision) {
96501
97199
  return false;
96502
97200
  return decision.x != null && decision.y != null;
96503
97201
  }
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.
97203
+ Every action MUST include:
97204
+ - "reason": one short sentence summarizing the action choice
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".`;
96504
97236
  var SYSTEM_PROMPT = `You are a mobile QA agent controlling an app using BOTH visual screenshots and screen snapshots (accessibility tree).
96505
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.
96506
97238
 
96507
- 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.
96508
- Every action MUST include:
96509
- - "reason": one short sentence summarizing the action choice
96510
- - "thoughts": 2\u20134 sentences describing what you see on screen and how that led to this action
97239
+ ${REPLY_FORMAT}
96511
97240
 
96512
97241
  Valid shapes:
96513
97242
  {"type":"tap","label":"Allow","reason":"...","thoughts":"..."}
@@ -96522,16 +97251,7 @@ Valid shapes:
96522
97251
  {"type":"type","text":"...","x":0-1000,"y":0-1000,"reason":"...","thoughts":"..."}
96523
97252
  {"type":"input","text":"...","id":"email_field","reason":"...","thoughts":"..."}
96524
97253
  {"type":"alert","alertAction":"accept","reason":"...","thoughts":"..."}
96525
- {"type":"activate-app","appId":"com.example.app","reason":"...","thoughts":"..."}
96526
- {"type":"terminate-app","appId":"com.example.app","reason":"...","thoughts":"..."}
96527
- {"type":"restart-app","appId":"com.example.app","reason":"...","thoughts":"..."}
96528
- {"type":"background-app","seconds":3,"reason":"...","thoughts":"..."}
96529
- {"type":"open-url","url":"https://example.com","reason":"...","thoughts":"..."}
96530
- {"type":"assert","assertion":"visible|not-visible","text":"...","reason":"...","thoughts":"..."}
96531
- {"type":"wait","ms":500-3000,"reason":"...","thoughts":"..."}
96532
- {"type":"verify","reason":"...","thoughts":"..."}
96533
- {"type":"done","reason":"...","thoughts":"..."}
96534
- {"type":"fail","reason":"...","thoughts":"..."}
97254
+ ${APP_LIFECYCLE_SHAPES}
96535
97255
 
96536
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.
96537
97257
 
@@ -96544,32 +97264,50 @@ Use BOTH the screenshot and the screen snapshot:
96544
97264
  - System permission / notification dialogs: {"type":"tap","label":"Allow"} or {"type":"alert","alertAction":"accept"}. Never guess coordinates for these.
96545
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.
96546
97266
 
96547
- Textfield input and keyboard handling:
96548
- - 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.
96549
- - When typing inside a textfield or when the keyboard is open:
96550
- 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.
96551
- 2. Nextline / Return on keyboard: Tap the Return / Done / Next / Search key on the virtual keyboard itself (using its x,y coordinates or label/id if present), or append "\\n" to your input text.
96552
- 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.
97267
+ ${KEYBOARD_GUIDANCE}
96553
97268
 
96554
- Swipe direction is the FINGER movement (same as Inspector):
96555
- - "down" (finger down, y 200\u2192800): scroll UP \u2014 reveal content above
96556
- - "up" (finger up, y 800\u2192200): scroll DOWN \u2014 reveal content below / to the bottom
96557
- - "left" / "right": horizontal paging
96558
- 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.
97269
+ ${SWIPE_GUIDANCE}
96559
97270
 
96560
97271
  Rules:
96561
97272
  - Look at the attached screenshot AND the screen snapshot before deciding.
96562
97273
  - Double-tap: {"type":"tap","double":true,...}. Long-press: {"type":"tap","durationMs":2000,...}. Drag a slider/chip with type drag, not swipe.
96563
- - 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.
96564
97274
  - Assert text on the tree with {"type":"assert","assertion":"visible","text":"\u2026"}.
96565
- - To scroll a list or page, emit swipe \u2014 never a tap in the middle of the screen as a stand-in for scrolling.
96566
- - "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.
96567
- - 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.
96568
- - 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.
96569
- - 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.
96570
- - Splash / loading / blank screens: return wait (do not tap imaginary tabs).
96571
- - Do not repeat the same tap target from Recent actions unless the screenshot still shows you are on the wrong screen.
96572
- - 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}`;
96573
97311
  function formatRecentActions(actions) {
96574
97312
  if (actions.length === 0)
96575
97313
  return "(none \u2014 first step)";
@@ -96616,6 +97354,11 @@ function formatRecentActions(actions) {
96616
97354
  `);
96617
97355
  }
96618
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
+ }
96619
97362
  function splitInstructionSteps(instructions) {
96620
97363
  const trimmed = instructions.replace(/\r\n/g, `
96621
97364
  `).trim();
@@ -96625,7 +97368,7 @@ function splitInstructionSteps(instructions) {
96625
97368
  `);
96626
97369
  const items = [];
96627
97370
  let current = [];
96628
- let sawList = false;
97371
+ let sawSteps = false;
96629
97372
  const pushCurrent = () => {
96630
97373
  const text3 = current.join(" ").replace(/\s+/g, " ").trim();
96631
97374
  if (text3)
@@ -96634,12 +97377,21 @@ function splitInstructionSteps(instructions) {
96634
97377
  };
96635
97378
  for (const rawLine of lines) {
96636
97379
  const line = rawLine.trim();
96637
- if (!line)
97380
+ if (!line) {
97381
+ if (current.length > 0) {
97382
+ sawSteps = true;
97383
+ pushCurrent();
97384
+ }
96638
97385
  continue;
97386
+ }
96639
97387
  if (LIST_ITEM_START_RE.test(line)) {
96640
- sawList = true;
97388
+ sawSteps = true;
96641
97389
  pushCurrent();
96642
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];
96643
97395
  } else if (current.length > 0) {
96644
97396
  current.push(line);
96645
97397
  } else {
@@ -96647,7 +97399,7 @@ function splitInstructionSteps(instructions) {
96647
97399
  }
96648
97400
  }
96649
97401
  pushCurrent();
96650
- if (!sawList)
97402
+ if (!sawSteps)
96651
97403
  return [trimmed];
96652
97404
  return items.length > 0 ? items : [trimmed];
96653
97405
  }
@@ -96666,11 +97418,29 @@ function flattenCaseInstructions(flows2) {
96666
97418
  }
96667
97419
  return queue.length > 0 ? queue : [{ instructions: "", expectedResult: "" }];
96668
97420
  }
97421
+ var MAX_COMPLETED_IN_PROMPT = 3;
96669
97422
  function formatCompletedInstructions(completed) {
96670
97423
  if (completed.length === 0)
96671
97424
  return "(none \u2014 this is the first instruction)";
96672
- 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(`
96673
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)`;
96674
97444
  }
96675
97445
  function formatDecidePrompt(input) {
96676
97446
  const ordinal = input.instructionOrdinal ?? input.stepIndex + 1;
@@ -96679,8 +97449,13 @@ function formatDecidePrompt(input) {
96679
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)";
96680
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}`;
96681
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);
96682
97453
  return [
96683
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
+ ] : [],
96684
97459
  `Test case: ${input.caseTitle}`,
96685
97460
  progress,
96686
97461
  "Completed instructions (already done \u2014 do not repeat):",
@@ -96692,9 +97467,19 @@ function formatDecidePrompt(input) {
96692
97467
  `Recent actions:
96693
97468
  ${formatRecentActions(input.recentActions ?? [])}`,
96694
97469
  `Last action error: ${input.lastError || "(none)"}`,
96695
- "Screen snapshot (cleaned accessibility tree, 0\u20131000 grid, x,y is top-left):",
96696
- input.screenSnapshot || "(unavailable)",
96697
- "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.",
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)"}`,
96698
97483
  'Reply with ONLY the JSON action object, including non-empty "reason" and "thoughts".'
96699
97484
  ].join(`
96700
97485
  `);
@@ -96705,12 +97490,51 @@ function isAbsurdNoScreenshotFail(decision) {
96705
97490
  const text3 = `${decision.reason} ${decision.thoughts}`.toLowerCase();
96706
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");
96707
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
+ }
96708
97530
  async function decideNextAction(input) {
96709
97531
  return completeVision(input.auth, {
96710
97532
  schema: agentDecisionSchema,
96711
- system: SYSTEM_PROMPT,
97533
+ system: input.screenshotOnly ? VISION_SYSTEM_PROMPT : SYSTEM_PROMPT,
96712
97534
  prompt: formatDecidePrompt(input),
96713
- imageBase64: input.imageBase64
97535
+ imageBase64: input.imageBase64,
97536
+ image: input.image,
97537
+ onDecideRetry: input.onDecideRetry
96714
97538
  });
96715
97539
  }
96716
97540
 
@@ -96877,6 +97701,7 @@ function mapApp(row) {
96877
97701
  name: row.name,
96878
97702
  prefix: row.prefix || row.id,
96879
97703
  context: row.context,
97704
+ knowledge: row.knowledge,
96880
97705
  iosBundleId: row.iosBundleId,
96881
97706
  iosAppStoreId: row.iosAppStoreId,
96882
97707
  androidApplicationId: row.androidApplicationId,
@@ -97062,6 +97887,7 @@ async function createApp(input) {
97062
97887
  name: name30,
97063
97888
  prefix,
97064
97889
  context: "",
97890
+ knowledge: "",
97065
97891
  iosBundleId: "",
97066
97892
  iosAppStoreId: "",
97067
97893
  androidApplicationId: "",
@@ -97075,6 +97901,7 @@ async function createApp(input) {
97075
97901
  }
97076
97902
  return created;
97077
97903
  }
97904
+ var MAX_APP_KNOWLEDGE_CHARS2 = 2048;
97078
97905
  async function updateApp(appId, input) {
97079
97906
  const db = getCatalogDb();
97080
97907
  const existing = await db.query.apps.findFirst({ where: eq(apps.id, appId) });
@@ -97085,11 +97912,15 @@ async function updateApp(appId, input) {
97085
97912
  if (!name30) {
97086
97913
  throw new CatalogValidationError("Application name is required");
97087
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
+ }
97088
97918
  const prefix = input.prefix !== undefined ? await allocatePrefix(input.prefix.trim() || name30, appId) : existing.prefix || await allocatePrefix(name30, appId);
97089
97919
  await db.update(apps).set({
97090
97920
  name: name30,
97091
97921
  prefix,
97092
97922
  context: input.context ?? existing.context,
97923
+ knowledge: input.knowledge ?? existing.knowledge,
97093
97924
  iosBundleId: input.iosBundleId ?? existing.iosBundleId,
97094
97925
  iosAppStoreId: input.iosAppStoreId ?? existing.iosAppStoreId,
97095
97926
  androidApplicationId: input.androidApplicationId ?? existing.androidApplicationId,
@@ -97619,8 +98450,8 @@ function createDevicesRoutes() {
97619
98450
  }
97620
98451
 
97621
98452
  // src/domains/doctor/application.ts
97622
- import { homedir as homedir10 } from "os";
97623
- import { join as join13 } from "path";
98453
+ import { homedir as homedir11 } from "os";
98454
+ import { join as join14 } from "path";
97624
98455
  async function runCommand5(command, options) {
97625
98456
  ensureHostToolPath();
97626
98457
  try {
@@ -97706,7 +98537,7 @@ async function probeJava() {
97706
98537
  let detail = javaBin ?? "java not on PATH";
97707
98538
  if (javaHome)
97708
98539
  detail = `JAVA_HOME=${javaHome}`;
97709
- else if (await Bun.file(join13(studioJbr, "bin/java")).exists()) {
98540
+ else if (await Bun.file(join14(studioJbr, "bin/java")).exists()) {
97710
98541
  detail = `Android Studio JBR available at ${studioJbr} (JAVA_HOME unset)`;
97711
98542
  }
97712
98543
  const ok = Boolean(javaBin || javaHome);
@@ -97737,7 +98568,7 @@ async function probeXcodeSelect() {
97737
98568
  };
97738
98569
  }
97739
98570
  async function probeAdbDevices() {
97740
- 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");
97741
98572
  if (!await Bun.file(adb).exists() && !await which3("adb")) {
97742
98573
  return {
97743
98574
  id: "adb-devices",
@@ -97938,10 +98769,10 @@ import {
97938
98769
  randomBytes
97939
98770
  } from "crypto";
97940
98771
  import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync, writeFileSync } from "fs";
97941
- import { homedir as homedir11 } from "os";
97942
- import { dirname as dirname3, join as join14 } from "path";
97943
- var YOQA_ROOT5 = join14(homedir11(), ".yoqa");
97944
- 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");
97945
98776
  var ALGORITHM = "aes-256-gcm";
97946
98777
  var IV_LENGTH = 12;
97947
98778
  var KEY_LENGTH = 32;
@@ -98733,13 +99564,12 @@ class ActionNotFoundError extends Error {
98733
99564
  this.name = "ActionNotFoundError";
98734
99565
  }
98735
99566
  }
98736
- async function performAction(session, body) {
99567
+ async function performAction(session, body, options) {
98737
99568
  const locatorTap = Boolean((body.id || body.label) && (body.kind === "tap" || body.kind === "input"));
98738
99569
  let x = body.x;
98739
99570
  let y = body.y;
98740
99571
  if (locatorTap) {
98741
- const screen = await getScreen(session, { full: false });
98742
- const elements = screen.elements ?? [];
99572
+ const elements = options?.screenElements ?? (await getScreen(session, { full: false })).elements ?? [];
98743
99573
  const match2 = body.id ? findElementById(elements, body.id) : findElementByLabel(elements, body.label ?? "");
98744
99574
  if (!match2) {
98745
99575
  throw new ActionNotFoundError(body.id ? `No element matching id: ${body.id}` : `No element matching label: ${body.label}`);
@@ -98839,6 +99669,11 @@ async function performAction(session, body) {
98839
99669
  // src/domains/runs/case-executor.ts
98840
99670
  var MAX_STEPS_PER_CASE = 25;
98841
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;
98842
99677
  var defaultClock = {
98843
99678
  sleep: (ms) => new Promise((resolve7) => setTimeout(resolve7, ms)),
98844
99679
  now: () => Date.now()
@@ -98885,6 +99720,12 @@ async function runTextAssert(input) {
98885
99720
  await input.clock.sleep(400);
98886
99721
  }
98887
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
+ }
98888
99729
  function commandForDecision(decision, defaultAppId) {
98889
99730
  if (decision.type === "verify" || decision.type === "done" || decision.type === "fail") {
98890
99731
  return null;
@@ -99261,25 +100102,49 @@ async function executeAgentCase(deps) {
99261
100102
  let caseError = null;
99262
100103
  let lastScreenshotUri = null;
99263
100104
  const recordedDecisions = [];
100105
+ let inFlightPhases = null;
100106
+ let inFlightLatencyMs = 0;
99264
100107
  const decideOnce = async (input) => {
99265
100108
  const payload = {
99266
100109
  auth: deps.auth,
99267
100110
  appContext: deps.appContext,
100111
+ appKnowledge: deps.appKnowledge,
99268
100112
  caseTitle: deps.catalogCase.name,
99269
100113
  instructions: input.flow.instructions,
99270
100114
  expectedResult: input.flow.expectedResult,
99271
100115
  stepIndex: stepIdx,
99272
100116
  imageBase64: input.imageBase64,
100117
+ image: input.image,
99273
100118
  recentActions: input.recentActions,
99274
100119
  screenSnapshot: input.screenSnapshot,
99275
100120
  lastError: input.lastError,
99276
100121
  defaultAppId: deps.defaultAppId,
99277
100122
  completedInstructions: [...input.completedInstructions],
99278
100123
  instructionOrdinal: input.instructionOrdinal,
99279
- 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;
99280
100144
  };
99281
100145
  let decision = await decide(payload);
99282
100146
  if (isAbsurdNoScreenshotFail(decision)) {
100147
+ input.onDecideRetry?.();
99283
100148
  decision = await decide(payload);
99284
100149
  if (isAbsurdNoScreenshotFail(decision)) {
99285
100150
  decision = {
@@ -99289,23 +100154,29 @@ async function executeAgentCase(deps) {
99289
100154
  };
99290
100155
  }
99291
100156
  }
99292
- decision = coerceScrollIntentToSwipe(decision);
99293
- decision = continueScrollingInsteadOfComplete({
99294
- decision,
99295
- instructions: input.flow.instructions,
99296
- expectedResult: input.flow.expectedResult,
99297
- recentActions: input.recentActions,
99298
- lastSwipeMovedScreen: input.lastSwipeMovedScreen
99299
- });
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
+ }
99300
100169
  if ((decision.type === "activate-app" || decision.type === "terminate-app" || decision.type === "restart-app") && !decision.appId && deps.defaultAppId) {
99301
100170
  decision = { ...decision, appId: deps.defaultAppId };
99302
100171
  }
99303
100172
  return decision;
99304
100173
  };
99305
- const applyDecision = async (decision) => {
100174
+ const applyDecision = async (decision, phases, screenElements) => {
99306
100175
  if (decision.type === "wait") {
99307
100176
  const waitMs = Math.min(3000, Math.max(500, decision.ms ?? 1500));
100177
+ const waitStarted = clock.now();
99308
100178
  await clock.sleep(waitMs);
100179
+ phases.actionMs += clock.now() - waitStarted;
99309
100180
  return "continue";
99310
100181
  }
99311
100182
  if (decision.type === "verify" || decision.type === "done") {
@@ -99317,6 +100188,7 @@ async function executeAgentCase(deps) {
99317
100188
  if (decision.type === "assert") {
99318
100189
  const assertion = decision.assertion === "not-visible" ? "not-visible" : "visible";
99319
100190
  const timeoutMs = Math.min(60000, Math.max(1000, decision.timeoutMs ?? 5000));
100191
+ const assertStarted = clock.now();
99320
100192
  await runTextAssert({
99321
100193
  session: deps.session,
99322
100194
  readScreen: readScreen2,
@@ -99326,14 +100198,18 @@ async function executeAgentCase(deps) {
99326
100198
  text: decision.text ?? "",
99327
100199
  timeoutMs
99328
100200
  });
100201
+ phases.actionMs += clock.now() - assertStarted;
99329
100202
  return "continue";
99330
100203
  }
99331
100204
  const body = decisionToActionRequest(decision, { defaultAppId: deps.defaultAppId });
99332
100205
  if (!body) {
99333
100206
  throw new Error(`${decision.type} is missing required fields`);
99334
100207
  }
100208
+ const actionStarted = clock.now();
99335
100209
  try {
99336
- await perform(deps.session, body);
100210
+ await perform(deps.session, body, {
100211
+ screenElements: screenElements.length > 0 ? screenElements : undefined
100212
+ });
99337
100213
  } catch (error40) {
99338
100214
  if (error40 instanceof ActionNotFoundError && (body.id || body.label) && decision.x != null && decision.y != null) {
99339
100215
  await perform(deps.session, {
@@ -99347,8 +100223,12 @@ async function executeAgentCase(deps) {
99347
100223
  } else {
99348
100224
  throw error40;
99349
100225
  }
100226
+ } finally {
100227
+ phases.actionMs += clock.now() - actionStarted;
99350
100228
  }
100229
+ const settleStarted = clock.now();
99351
100230
  await clock.sleep(settleMs);
100231
+ phases.settleMs += clock.now() - settleStarted;
99352
100232
  return "continue";
99353
100233
  };
99354
100234
  try {
@@ -99356,6 +100236,65 @@ async function executeAgentCase(deps) {
99356
100236
  const recentActions = [];
99357
100237
  const completedInstructions = [];
99358
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
+ };
99359
100298
  for (let instructionIndex = 0;instructionIndex < instructionQueue.length; instructionIndex++) {
99360
100299
  const instruction = instructionQueue[instructionIndex];
99361
100300
  if (!instruction)
@@ -99365,17 +100304,158 @@ async function executeAgentCase(deps) {
99365
100304
  break;
99366
100305
  }
99367
100306
  let instructionDone = false;
100307
+ let pending = null;
100308
+ let pendingElements = [];
99368
100309
  for (let attempt = 0;attempt < maxSteps && !instructionDone; attempt++) {
99369
100310
  if (deps.isAborted()) {
99370
100311
  caseStatus = "cancelled";
99371
100312
  instructionDone = true;
99372
100313
  break;
99373
100314
  }
99374
- 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;
99375
100405
  const shot = await deps.session.screenshot();
99376
100406
  lastScreenshotUri = shot.path;
99377
- 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");
99378
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");
99379
100459
  const lastAction = recentActions.at(-1);
99380
100460
  const lastSwipeMovedScreen = lastAction?.type === "swipe" && prevFingerprint != null && fingerprint !== prevFingerprint;
99381
100461
  if (deps.isAborted()) {
@@ -99385,69 +100465,106 @@ async function executeAgentCase(deps) {
99385
100465
  }
99386
100466
  const decideInput = {
99387
100467
  flow: instruction,
99388
- imageBase64: shot.base64,
100468
+ imageBase64: visionBase64,
100469
+ image,
99389
100470
  recentActions,
99390
100471
  screenSnapshot: tree.snapshot,
100472
+ lastError: stallHint,
99391
100473
  lastSwipeMovedScreen,
99392
100474
  completedInstructions,
99393
100475
  instructionOrdinal: instructionIndex + 1,
99394
- 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
99395
100490
  };
99396
100491
  let decision = await decideOnce(decideInput);
99397
100492
  prevFingerprint = fingerprint;
99398
- 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;
99399
100523
  if (deps.isAborted()) {
99400
100524
  caseStatus = "cancelled";
99401
100525
  instructionDone = true;
99402
100526
  break;
99403
100527
  }
99404
- const applyWithRetry = async () => {
99405
- try {
99406
- return await applyDecision(decision);
99407
- } catch (error40) {
99408
- if (!isRetriableActionError(error40) || deps.isAborted())
99409
- throw error40;
99410
- const failed = decision;
99411
- decision = await decideOnce({
99412
- ...decideInput,
99413
- recentActions: [...recentActions, failed],
99414
- lastError: error40 instanceof Error ? error40.message : String(error40)
99415
- });
99416
- const retryCommand = commandForDecision(decision, deps.defaultAppId);
99417
- await setCurrentCommand(retryCommand);
99418
- return await applyDecision(decision);
99419
- }
99420
- };
99421
- const recordStep = async (command, outcome2) => {
99422
- recentActions.push(decision);
99423
- recordedDecisions.push(decision);
99424
- await deps.appendStep({
99425
- idx: stepIdx,
99426
- action: decision,
99427
- screenshotUri: shot.path,
99428
- ok: outcome2 !== "fail",
99429
- latencyMs,
99430
- 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),
99431
- command
99432
- });
99433
- };
99434
- const initialCommand = commandForDecision(decision, deps.defaultAppId);
99435
- const outcome = initialCommand ? await withCurrentCommand(setCurrentCommand, initialCommand, async () => {
99436
- const result = await applyWithRetry();
99437
- await recordStep(commandForDecision(decision, deps.defaultAppId), result);
99438
- return result;
99439
- }) : await (async () => {
99440
- const result = await applyWithRetry();
99441
- await recordStep(null, result);
99442
- return result;
99443
- })();
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
+ });
99444
100556
  if (outcome === "done") {
99445
100557
  instructionDone = true;
99446
100558
  completedInstructions.push(instruction.instructions);
100559
+ pending = null;
99447
100560
  } else if (outcome === "fail") {
99448
100561
  caseStatus = "errored";
99449
- caseError = decision.reason ?? "Agent marked the instruction as failed";
100562
+ caseError = verdict.reason || decision.reason || "Agent marked the instruction as failed";
99450
100563
  instructionDone = true;
100564
+ pending = null;
100565
+ } else {
100566
+ pending = isDeviceDecision(decision) ? decision : null;
100567
+ pendingElements = tree.elements;
99451
100568
  }
99452
100569
  stepIdx += 1;
99453
100570
  }
@@ -99474,7 +100591,8 @@ async function executeAgentCase(deps) {
99474
100591
  },
99475
100592
  screenshotUri: lastScreenshotUri,
99476
100593
  ok: false,
99477
- latencyMs: 0,
100594
+ latencyMs: inFlightLatencyMs,
100595
+ phases: inFlightPhases,
99478
100596
  detail: caseError,
99479
100597
  command: null
99480
100598
  });
@@ -99516,6 +100634,15 @@ function parseAction(raw2) {
99516
100634
  return {};
99517
100635
  }
99518
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
+ }
99519
100646
  function isAborted2(runId) {
99520
100647
  return runControls.get(runId)?.aborted === true;
99521
100648
  }
@@ -99535,6 +100662,9 @@ function parseRunExecutionMode(value) {
99535
100662
  return value;
99536
100663
  return "auto";
99537
100664
  }
100665
+ function parseRunScreenMode(value) {
100666
+ return value === "vision" ? "vision" : "tree";
100667
+ }
99538
100668
  function resolveCaseExecutionMode(runMode, hasScript) {
99539
100669
  if (runMode === "agent")
99540
100670
  return "agent";
@@ -99559,6 +100689,7 @@ async function loadRun(runId) {
99559
100689
  screenshotUri: step.screenshotUri,
99560
100690
  ok: step.ok === 1,
99561
100691
  latencyMs: step.latencyMs,
100692
+ phases: parsePhases(step.phasesJson),
99562
100693
  detail: step.detail,
99563
100694
  command: step.command ?? null,
99564
100695
  createdAt: step.createdAt
@@ -99585,6 +100716,7 @@ async function loadRun(runId) {
99585
100716
  buildId: runRow.buildId,
99586
100717
  status: runRow.status,
99587
100718
  executionMode: parseRunExecutionMode(runRow.executionMode),
100719
+ screenMode: parseRunScreenMode(runRow.screenMode),
99588
100720
  error: runRow.error,
99589
100721
  createdAt: runRow.createdAt,
99590
100722
  startedAt: runRow.startedAt,
@@ -99602,6 +100734,7 @@ async function appendStep(input) {
99602
100734
  screenshotUri: input.screenshotUri,
99603
100735
  ok: input.ok ? 1 : 0,
99604
100736
  latencyMs: Math.max(0, Math.round(input.latencyMs)),
100737
+ phasesJson: input.phases ? JSON.stringify(input.phases) : null,
99605
100738
  detail: input.detail,
99606
100739
  command: input.command,
99607
100740
  createdAt: Date.now()
@@ -99668,6 +100801,7 @@ async function executeAgentCase2(input) {
99668
100801
  return executeAgentCase({
99669
100802
  catalogCase,
99670
100803
  appContext: input.appContext,
100804
+ appKnowledge: input.appKnowledge,
99671
100805
  auth: input.auth,
99672
100806
  session: input.session,
99673
100807
  isAborted: () => isAborted2(input.runId),
@@ -99681,6 +100815,7 @@ async function executeAgentCase2(input) {
99681
100815
  await setCurrentCommand(input.runTestId, command);
99682
100816
  },
99683
100817
  decide: decideNextAction,
100818
+ screenMode: input.screenMode,
99684
100819
  clock: { sleep, now: () => Date.now() },
99685
100820
  defaultAppId: input.defaultAppId
99686
100821
  });
@@ -99722,8 +100857,10 @@ async function executeCase(input) {
99722
100857
  runTestId: input.runTestId,
99723
100858
  caseId: input.caseId,
99724
100859
  appContext: input.appContext,
100860
+ appKnowledge: input.appKnowledge,
99725
100861
  session: input.session,
99726
100862
  auth: input.auth,
100863
+ screenMode: input.screenMode,
99727
100864
  defaultAppId: input.defaultAppId
99728
100865
  });
99729
100866
  caseStatus = result.status;
@@ -99764,6 +100901,7 @@ async function executeRun(runId) {
99764
100901
  }
99765
100902
  const startedAt = Date.now();
99766
100903
  await db.update(runs).set({ status: "running", startedAt, error: null }).where(eq(runs.id, runId));
100904
+ pruneOldRunScreenshots();
99767
100905
  if (isAborted2(runId)) {
99768
100906
  await persistCancelled(runId);
99769
100907
  return;
@@ -99834,9 +100972,11 @@ async function executeRun(runId) {
99834
100972
  runTestId: test.id,
99835
100973
  caseId: test.caseId,
99836
100974
  appContext: app.context,
100975
+ appKnowledge: app.knowledge,
99837
100976
  session,
99838
100977
  auth,
99839
100978
  caseMode,
100979
+ screenMode: run.screenMode ?? "tree",
99840
100980
  defaultAppId: run.platform === "ios" ? app.iosBundleId || undefined : app.androidApplicationId || undefined
99841
100981
  });
99842
100982
  if (status === "cancelled" || isAborted2(runId)) {
@@ -99901,6 +101041,7 @@ async function createRun(input) {
99901
101041
  throw new RunValidationError("App not found");
99902
101042
  }
99903
101043
  const executionMode = input.executionMode ?? "auto";
101044
+ const screenMode = input.screenMode ?? "vision";
99904
101045
  const catalogCases = [];
99905
101046
  for (const caseId of uniqueCaseIds) {
99906
101047
  const catalogCase = await getCase(caseId);
@@ -99942,6 +101083,7 @@ async function createRun(input) {
99942
101083
  buildId,
99943
101084
  status: "queued",
99944
101085
  executionMode,
101086
+ screenMode,
99945
101087
  error: null,
99946
101088
  createdAt: now,
99947
101089
  startedAt: null,
@@ -100011,6 +101153,7 @@ async function listRuns(appId) {
100011
101153
  buildId: runRow.buildId,
100012
101154
  status: runRow.status,
100013
101155
  executionMode: parseRunExecutionMode(runRow.executionMode),
101156
+ screenMode: parseRunScreenMode(runRow.screenMode),
100014
101157
  error: runRow.error,
100015
101158
  createdAt: runRow.createdAt,
100016
101159
  startedAt: runRow.startedAt,
@@ -100245,8 +101388,84 @@ function createServersRoutes() {
100245
101388
  }
100246
101389
 
100247
101390
  // src/interfaces/http/session.ts
100248
- import { mkdir as mkdir4, rename as rename2 } from "fs/promises";
101391
+ import { mkdir as mkdir5, rename as rename2 } from "fs/promises";
100249
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
100250
101469
  function sessionErrorResponse(error40) {
100251
101470
  const message = error40 instanceof Error ? error40.message : String(error40);
100252
101471
  if (error40 instanceof SessionBusyError) {
@@ -100345,7 +101564,7 @@ function createSessionRoutes() {
100345
101564
  const shot = await session.screenshot();
100346
101565
  let path = shot.path;
100347
101566
  if (parsed.data.path) {
100348
- await mkdir4(dirname4(parsed.data.path), { recursive: true });
101567
+ await mkdir5(dirname4(parsed.data.path), { recursive: true });
100349
101568
  await rename2(shot.path, parsed.data.path);
100350
101569
  path = parsed.data.path;
100351
101570
  }
@@ -100439,7 +101658,7 @@ function createSessionRoutes() {
100439
101658
  error: "A run is using this device session. Cancel the run to interact manually."
100440
101659
  }, 409);
100441
101660
  }
100442
- const result = await performAction(session, parsed.data);
101661
+ const result = parsed.data.screenshot ? await performActionWithScreenshot(session, parsed.data) : await performAction(session, parsed.data);
100443
101662
  return c.json(actionResponseSchema.parse(result));
100444
101663
  } catch (error40) {
100445
101664
  const gone = sessionErrorResponse(error40);