@tangle-network/browser-agent-driver 0.14.5 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/brain/index.d.ts +65 -0
  2. package/dist/brain/index.d.ts.map +1 -1
  3. package/dist/brain/index.js +213 -14
  4. package/dist/brain/index.js.map +1 -1
  5. package/dist/cli-view-live.d.ts +60 -0
  6. package/dist/cli-view-live.d.ts.map +1 -0
  7. package/dist/cli-view-live.js +216 -0
  8. package/dist/cli-view-live.js.map +1 -0
  9. package/dist/cli-view.d.ts.map +1 -1
  10. package/dist/cli-view.js +4 -1
  11. package/dist/cli-view.js.map +1 -1
  12. package/dist/cli.js +57 -1
  13. package/dist/cli.js.map +1 -1
  14. package/dist/drivers/cursor-overlay.d.ts +0 -5
  15. package/dist/drivers/cursor-overlay.d.ts.map +1 -1
  16. package/dist/drivers/cursor-overlay.js +0 -5
  17. package/dist/drivers/cursor-overlay.js.map +1 -1
  18. package/dist/drivers/playwright.d.ts +3 -3
  19. package/dist/drivers/playwright.d.ts.map +1 -1
  20. package/dist/drivers/playwright.js +14 -12
  21. package/dist/drivers/playwright.js.map +1 -1
  22. package/dist/extensions/loader.d.ts +40 -0
  23. package/dist/extensions/loader.d.ts.map +1 -0
  24. package/dist/extensions/loader.js +85 -0
  25. package/dist/extensions/loader.js.map +1 -0
  26. package/dist/extensions/types.d.ts +132 -0
  27. package/dist/extensions/types.d.ts.map +1 -0
  28. package/dist/extensions/types.js +161 -0
  29. package/dist/extensions/types.js.map +1 -0
  30. package/dist/runner/decision-cache.d.ts +105 -0
  31. package/dist/runner/decision-cache.d.ts.map +1 -0
  32. package/dist/runner/decision-cache.js +150 -0
  33. package/dist/runner/decision-cache.js.map +1 -0
  34. package/dist/runner/deterministic-patterns.d.ts +41 -0
  35. package/dist/runner/deterministic-patterns.d.ts.map +1 -0
  36. package/dist/runner/deterministic-patterns.js +137 -0
  37. package/dist/runner/deterministic-patterns.js.map +1 -0
  38. package/dist/runner/events.d.ts +224 -0
  39. package/dist/runner/events.d.ts.map +1 -0
  40. package/dist/runner/events.js +147 -0
  41. package/dist/runner/events.js.map +1 -0
  42. package/dist/runner/runner.d.ts +22 -0
  43. package/dist/runner/runner.d.ts.map +1 -1
  44. package/dist/runner/runner.js +304 -13
  45. package/dist/runner/runner.js.map +1 -1
  46. package/dist/test-runner.d.ts +19 -1
  47. package/dist/test-runner.d.ts.map +1 -1
  48. package/dist/test-runner.js +18 -3
  49. package/dist/test-runner.js.map +1 -1
  50. package/dist/types.d.ts +7 -0
  51. package/dist/types.d.ts.map +1 -1
  52. package/package.json +1 -1
@@ -31,6 +31,9 @@ import { detectAiTanglePartnerTemplateVisibleState, detectAiTangleVerifiedOutput
31
31
  import { shouldUseVisibleLinkScout, shouldUseVisibleLinkScoutPage, shouldUseBoundedBranchExplorer, inspectBranchPreview, scoreBranchPreview } from './scout.js';
32
32
  import { buildOverrideProducers, buildScoutLinkRecommendationText, buildBranchLinkRecommendationText } from './overrides.js';
33
33
  import { RunRegistry } from '../memory/run-registry.js';
34
+ import { ensureBus } from './events.js';
35
+ import { DecisionCache } from './decision-cache.js';
36
+ import { matchDeterministicPattern } from './deterministic-patterns.js';
34
37
  /** Build a structured session record from a completed run */
35
38
  function buildSession(scenario, result) {
36
39
  const lastTurn = result.turns[result.turns.length - 1];
@@ -79,6 +82,14 @@ export class BrowserAgent {
79
82
  knowledge;
80
83
  selectorCache;
81
84
  cachedPostState;
85
+ bus;
86
+ currentRunId = '';
87
+ // In-session decision cache. Lazy-skips brain.decide() when the (snapshot,
88
+ // url, goal, last-effect, budget-bucket) is byte-identical to a previous
89
+ // turn in this run. The cache is fresh per `run()` invocation — never
90
+ // persists, never crosses runs.
91
+ decisionCache;
92
+ extensions;
82
93
  constructor(options) {
83
94
  this.driver = options.driver;
84
95
  this.config = options.config || {};
@@ -86,6 +97,17 @@ export class BrowserAgent {
86
97
  this.onTurn = options.onTurn;
87
98
  this.onPhaseTiming = options.onPhaseTiming;
88
99
  this.referenceTrajectory = options.referenceTrajectory;
100
+ this.bus = ensureBus(options.eventBus);
101
+ this.extensions = options.extensions;
102
+ if (this.extensions) {
103
+ // Forward extension-supplied prompt rules into the brain so they get
104
+ // included in every system prompt build. Domain-keyed rules are
105
+ // matched per-turn against the current URL inside composeSystemPromptParts.
106
+ this.brain.setExtensionRules(this.extensions.combinedRules, this.extensions.combinedDomainRules);
107
+ // Subscribe extensions to the event bus so onTurnEvent fires for
108
+ // every emitted event without callers having to wire it up.
109
+ this.bus.subscribe(this.extensions.fanOutTurnEvent, false);
110
+ }
89
111
  this.projectStore = options.projectStore;
90
112
  this.runRegistry = options.runRegistry;
91
113
  }
@@ -100,7 +122,19 @@ export class BrowserAgent {
100
122
  const runId = scenario.sessionId
101
123
  ? `${scenario.sessionId}_${Date.now()}`
102
124
  : RunRegistry.generateRunId();
125
+ this.currentRunId = runId;
103
126
  const domain = safeHostname(scenario.startUrl || '') || 'unknown';
127
+ // Emit run-started so subscribers (live viewer, events.jsonl sink) can
128
+ // initialize their state. The bus is a no-op when no eventBus was passed
129
+ // to the constructor, so this is free for non-live runs.
130
+ this.bus.emitNow({
131
+ type: 'run-started',
132
+ runId,
133
+ turn: 0,
134
+ goal: scenario.goal,
135
+ startUrl: scenario.startUrl,
136
+ maxTurns,
137
+ });
104
138
  const buildResult = (result) => {
105
139
  const agentResult = {
106
140
  ...result,
@@ -118,6 +152,17 @@ export class BrowserAgent {
118
152
  reason: agentResult.reason,
119
153
  turnCount: agentResult.turns.length,
120
154
  });
155
+ // Emit run-completed so the live viewer can swap to a "finished" UI
156
+ // and the events.jsonl sink can flush its tail.
157
+ this.bus.emitNow({
158
+ type: 'run-completed',
159
+ runId,
160
+ turn: 0,
161
+ success: agentResult.success,
162
+ totalTurns: agentResult.turns.length,
163
+ totalMs: agentResult.totalMs,
164
+ ...(agentResult.reason ? { reason: agentResult.reason } : {}),
165
+ });
121
166
  return agentResult;
122
167
  };
123
168
  // Wrap onTurn to include mid-run manifest updates (every 3 turns)
@@ -138,6 +183,21 @@ export class BrowserAgent {
138
183
  this.brain.reset();
139
184
  this.cachedPostState = undefined;
140
185
  let executeTimeoutRecoveries = 0;
186
+ // Fresh decision cache per run. The cache is strictly in-session — page
187
+ // state changes silently between runs and a stale cached decision is a
188
+ // correctness landmine. Disable via BAD_DECISION_CACHE=0.
189
+ this.decisionCache = process.env.BAD_DECISION_CACHE === '0'
190
+ ? undefined
191
+ : new DecisionCache();
192
+ // Track the previous turn's expectedEffect so we can include it in the
193
+ // cache key. Empty string for turn 1.
194
+ let lastEffectForCacheKey = '';
195
+ // Pre-warm the provider connection in parallel with everything else.
196
+ // Without this, turn 1's first LLM call eats 600ms (Anthropic) to
197
+ // 1200ms (OpenAI) of cold-start TLS+DNS+HTTP/2 setup. By the time
198
+ // navigation + first observe complete, the connection pool is hot.
199
+ // Best-effort: failure is swallowed and turn 1 pays the cold-start.
200
+ const warmupPromise = this.brain.warmup();
141
201
  // Start navigation and load memory in parallel. Navigation is async (network
142
202
  // I/O) while memory init is sync (readFileSync), so memory completes while
143
203
  // the network request is in flight — saving the serial cost of disk reads.
@@ -153,6 +213,9 @@ export class BrowserAgent {
153
213
  phaseTimings.initialNavigateMs = Date.now() - navigateStartedAt;
154
214
  this.onPhaseTiming?.('navigate', phaseTimings.initialNavigateMs);
155
215
  }
216
+ // Don't wait on warmup before entering the loop — it races against the
217
+ // first observe and decode. Make sure any unhandled rejection is silenced.
218
+ void warmupPromise.catch(() => undefined);
156
219
  // Write run manifest at start
157
220
  this.runRegistry?.startRun({
158
221
  runId,
@@ -182,9 +245,17 @@ export class BrowserAgent {
182
245
  });
183
246
  }
184
247
  const turnStart = Date.now();
248
+ this.bus.emitNow({ type: 'turn-started', runId, turn: i });
185
249
  try {
186
250
  // -- 1. Check for recovery before observing --
187
- if (turns.length >= 2) {
251
+ // Only run analyzeRecovery when there's a non-zero error trail. Used
252
+ // to run unconditionally; lazy-skipping it when there are no recent
253
+ // errors avoids the per-turn cost on the happy path. (Gen 5 lazy
254
+ // decision graph computation, change #20 in the pursuit spec.)
255
+ const hasErrorTrail = turns.length >= 2
256
+ && (runState.consecutiveErrors > 0
257
+ || turns.slice(-5).some((t) => t.error || t.verified === false));
258
+ if (hasErrorTrail) {
188
259
  const lastState = turns[turns.length - 1]?.state || { url: '', title: '', snapshot: '' };
189
260
  const recovery = analyzeRecovery({
190
261
  recentTurns: turns.slice(-5),
@@ -192,6 +263,15 @@ export class BrowserAgent {
192
263
  consecutiveErrors: runState.consecutiveErrors,
193
264
  });
194
265
  if (recovery) {
266
+ const forcedActionLabel = recovery.forceBrowserAction?.action ?? recovery.forceAction;
267
+ this.bus.emitNow({
268
+ type: 'recovery-fired',
269
+ runId,
270
+ turn: i,
271
+ strategy: recovery.strategy,
272
+ feedback: recovery.feedback,
273
+ ...(forcedActionLabel ? { forcedAction: forcedActionLabel } : {}),
274
+ });
195
275
  if (this.config.debug) {
196
276
  const forced = recovery.forceBrowserAction
197
277
  ? ` (force: ${recovery.forceBrowserAction.action})`
@@ -242,6 +322,7 @@ export class BrowserAgent {
242
322
  // -- 2. Observe (with retry) --
243
323
  // Reuse the snapshot from verifyEffect if available (no page mutations between them)
244
324
  const observeStartedAt = Date.now();
325
+ this.bus.emitNow({ type: 'observe-started', runId, turn: i });
245
326
  const state = this.cachedPostState ?? await withRetry(() => this.driver.observe(), 1, // Observe failures are DOM access issues, not transient — retrying 3x wastes 3s
246
327
  retryDelayMs, (attempt, err) => {
247
328
  if (this.config.debug) {
@@ -249,10 +330,29 @@ export class BrowserAgent {
249
330
  }
250
331
  }, scenario.signal);
251
332
  this.cachedPostState = undefined;
333
+ const observeDurationMs = Date.now() - observeStartedAt;
252
334
  if (phaseTimings.firstObserveMs === undefined) {
253
- phaseTimings.firstObserveMs = Date.now() - observeStartedAt;
335
+ phaseTimings.firstObserveMs = observeDurationMs;
254
336
  this.onPhaseTiming?.('observe', phaseTimings.firstObserveMs);
255
337
  }
338
+ // Snapshot bytes only — never wire the full snapshot, it's huge.
339
+ // Screenshot data URL travels through observe-completed when vision
340
+ // is on so the live viewer can render it without a separate fetch.
341
+ const screenshotDataUrl = state.screenshot
342
+ ? (state.screenshot.startsWith('data:')
343
+ ? state.screenshot
344
+ : `data:image/jpeg;base64,${state.screenshot}`)
345
+ : undefined;
346
+ this.bus.emitNow({
347
+ type: 'observe-completed',
348
+ runId,
349
+ turn: i,
350
+ url: state.url,
351
+ title: state.title,
352
+ snapshotBytes: state.snapshot.length,
353
+ ...(screenshotDataUrl ? { screenshot: screenshotDataUrl } : {}),
354
+ durationMs: observeDurationMs,
355
+ });
256
356
  // Auto-navigate: if we're on about:blank with a startUrl, navigate without
257
357
  // consuming an LLM turn. The agent always does wait->navigate on blank pages.
258
358
  if (state.url === 'about:blank' &&
@@ -571,18 +671,106 @@ export class BrowserAgent {
571
671
  const decisionState = forceVision
572
672
  ? await this.attachDecisionScreenshot(state)
573
673
  : state;
574
- // -- 4. Decide (with retry) --
674
+ // -- 4. Decide (deterministic patterns → cache → LLM) --
575
675
  const decideStartedAt = Date.now();
576
- const decision = await withRetry(() => this.brain.decide(scenario.goal, decisionState, finalExtraContext || undefined, { current: i, max: maxTurns }, { forceVision }), retries, retryDelayMs, (attempt, err) => {
676
+ this.bus.emitNow({ type: 'decide-started', runId, turn: i });
677
+ // Lazy decisions, level 1: deterministic UI pattern matching. If the
678
+ // page is a recognized pattern (cookie banner with single Accept,
679
+ // single-button modal close), the action is obvious and we skip the
680
+ // LLM entirely. Pattern matchers are bypassed under the same
681
+ // conditions as the cache below.
682
+ const previousTurn = turns[turns.length - 1];
683
+ const canSkipDecide = !forceVision
684
+ && !finalExtraContext
685
+ && !previousTurn?.error
686
+ && !previousTurn?.verificationFailure
687
+ && process.env.BAD_PATTERN_SKIP !== '0';
688
+ let patternMatch = null;
689
+ if (canSkipDecide) {
690
+ patternMatch = matchDeterministicPattern(decisionState);
691
+ }
692
+ // Lazy decisions, level 2: in-session decision cache.
693
+ const canUseCache = this.decisionCache !== undefined
694
+ && canSkipDecide
695
+ && !patternMatch;
696
+ const cacheKey = canUseCache
697
+ ? {
698
+ snapshotHash: DecisionCache.hashSnapshot(decisionState.snapshot),
699
+ url: decisionState.url,
700
+ goal: scenario.goal,
701
+ lastEffect: lastEffectForCacheKey,
702
+ budgetBucket: DecisionCache.budgetBucket(i, maxTurns),
703
+ }
704
+ : undefined;
705
+ const cached = canUseCache && cacheKey
706
+ ? this.decisionCache.get(cacheKey)
707
+ : undefined;
708
+ let decision;
709
+ if (patternMatch) {
710
+ // Pattern match — synthesize a decision, no LLM call.
711
+ decision = {
712
+ action: patternMatch.action,
713
+ raw: '[deterministic-pattern]',
714
+ reasoning: patternMatch.reasoning,
715
+ expectedEffect: patternMatch.expectedEffect,
716
+ tokensUsed: 0,
717
+ inputTokens: 0,
718
+ outputTokens: 0,
719
+ };
720
+ this.bus.emitNow({
721
+ type: 'decide-skipped-pattern',
722
+ runId,
723
+ turn: i,
724
+ action: decision.action,
725
+ patternId: patternMatch.patternId,
726
+ });
577
727
  if (this.config.debug) {
578
- console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
728
+ console.log(`[Runner] Pattern SKIP (${patternMatch.patternId}, turn ${i}) — skipping LLM`);
579
729
  }
580
- }, scenario.signal);
730
+ }
731
+ else if (cached) {
732
+ // Cache hit — replay the decision, no LLM call.
733
+ decision = cached.decision;
734
+ this.bus.emitNow({
735
+ type: 'decide-skipped-cached',
736
+ runId,
737
+ turn: i,
738
+ action: decision.action,
739
+ cacheKey: cached.hash,
740
+ });
741
+ if (this.config.debug) {
742
+ console.log(`[Runner] Decision cache HIT (turn ${i}, key ${cached.hash.slice(0, 8)}…) — skipping LLM`);
743
+ }
744
+ }
745
+ else {
746
+ decision = await withRetry(() => this.brain.decide(scenario.goal, decisionState, finalExtraContext || undefined, { current: i, max: maxTurns }, { forceVision }), retries, retryDelayMs, (attempt, err) => {
747
+ if (this.config.debug) {
748
+ console.log(`[Runner] LLM retry ${attempt}: ${err.message}`);
749
+ }
750
+ }, scenario.signal);
751
+ if (cacheKey && this.decisionCache) {
752
+ // Store the fresh decision so a future identical turn replays it.
753
+ this.decisionCache.set(cacheKey, decision);
754
+ }
755
+ }
756
+ const decideDurationMs = Date.now() - decideStartedAt;
581
757
  if (phaseTimings.firstDecideMs === undefined) {
582
- phaseTimings.firstDecideMs = Date.now() - decideStartedAt;
758
+ phaseTimings.firstDecideMs = decideDurationMs;
583
759
  this.onPhaseTiming?.('decide', phaseTimings.firstDecideMs);
584
760
  }
585
- let { action, nextActions, raw, reasoning, plan, currentStep, expectedEffect, tokensUsed, inputTokens, outputTokens, modelUsed } = decision;
761
+ let { action, nextActions, raw, reasoning, plan, currentStep, expectedEffect, tokensUsed, inputTokens, outputTokens, cacheReadInputTokens, cacheCreationInputTokens, modelUsed } = decision;
762
+ this.bus.emitNow({
763
+ type: 'decide-completed',
764
+ runId,
765
+ turn: i,
766
+ action,
767
+ ...(reasoning ? { reasoning } : {}),
768
+ ...(expectedEffect ? { expectedEffect } : {}),
769
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
770
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
771
+ ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
772
+ durationMs: decideDurationMs,
773
+ });
586
774
  // -- 4b. Override pipeline — scored selection of post-decision overrides --
587
775
  const overrideCtx = {
588
776
  state,
@@ -602,6 +790,42 @@ export class BrowserAgent {
602
790
  reasoning = `${reasoning}\n[${overrideWinner.reasoningTag}] ${overrideWinner.feedback}`;
603
791
  expectedEffect = overrideWinner.expectedEffect;
604
792
  nextActions = [];
793
+ this.bus.emitNow({
794
+ type: 'override-applied',
795
+ runId,
796
+ turn: i,
797
+ source: 'override-pipeline',
798
+ reasoningTag: overrideWinner.reasoningTag,
799
+ feedback: overrideWinner.feedback,
800
+ });
801
+ }
802
+ // -- 4c. User extension mutateDecision (final say) --
803
+ // Runs AFTER the built-in override pipeline so user extensions can
804
+ // veto or replace any decision the built-ins might have produced.
805
+ // Mutations are emitted as override events on the bus for audit.
806
+ if (this.extensions?.applyMutateDecision) {
807
+ const mutated = this.extensions.applyMutateDecision({ ...decision, action, reasoning, expectedEffect }, {
808
+ goal: scenario.goal,
809
+ turn: i,
810
+ maxTurns,
811
+ state: decisionState,
812
+ ...(turns[turns.length - 1]?.error
813
+ ? { lastError: turns[turns.length - 1].error }
814
+ : {}),
815
+ });
816
+ if (mutated.mutated) {
817
+ action = mutated.decision.action;
818
+ reasoning = mutated.decision.reasoning ?? reasoning;
819
+ expectedEffect = mutated.decision.expectedEffect ?? expectedEffect;
820
+ this.bus.emitNow({
821
+ type: 'override-applied',
822
+ runId,
823
+ turn: i,
824
+ source: 'extension',
825
+ reasoningTag: mutated.sources.join(','),
826
+ feedback: 'extension mutateDecision applied',
827
+ });
828
+ }
605
829
  }
606
830
  const turn = {
607
831
  turn: i,
@@ -615,6 +839,8 @@ export class BrowserAgent {
615
839
  tokensUsed,
616
840
  inputTokens,
617
841
  outputTokens,
842
+ cacheReadInputTokens,
843
+ cacheCreationInputTokens,
618
844
  modelUsed,
619
845
  durationMs: Date.now() - turnStart,
620
846
  };
@@ -892,8 +1118,9 @@ export class BrowserAgent {
892
1118
  // where withOverlayRecovery × withRetry multiplied a 22s timeout to 135s).
893
1119
  const executeWallClockMs = 45_000;
894
1120
  let execResult;
1121
+ const executeStartedAt = Date.now();
1122
+ this.bus.emitNow({ type: 'execute-started', runId, turn: i, action });
895
1123
  try {
896
- const executeStartedAt = Date.now();
897
1124
  const executePromise = withRetry(() => this.driver.execute(action), retries, retryDelayMs, (attempt, err) => {
898
1125
  if (this.config.debug) {
899
1126
  console.log(`[Runner] Execute retry ${attempt}: ${err.message}`);
@@ -905,8 +1132,27 @@ export class BrowserAgent {
905
1132
  phaseTimings.firstExecuteMs = Date.now() - executeStartedAt;
906
1133
  this.onPhaseTiming?.('execute', phaseTimings.firstExecuteMs);
907
1134
  }
1135
+ this.bus.emitNow({
1136
+ type: 'execute-completed',
1137
+ runId,
1138
+ turn: i,
1139
+ action,
1140
+ success: execResult.success,
1141
+ ...(execResult.error ? { error: execResult.error } : {}),
1142
+ ...(execResult.bounds ? { bounds: execResult.bounds } : {}),
1143
+ durationMs: Date.now() - executeStartedAt,
1144
+ });
908
1145
  }
909
1146
  catch (err) {
1147
+ this.bus.emitNow({
1148
+ type: 'execute-completed',
1149
+ runId,
1150
+ turn: i,
1151
+ action,
1152
+ success: false,
1153
+ error: err instanceof Error ? err.message : String(err),
1154
+ durationMs: Date.now() - executeStartedAt,
1155
+ });
910
1156
  if (err instanceof StaleRefError) {
911
1157
  // Stale ref — re-observe and cache for next turn (avoid double observe).
912
1158
  // Only inject available refs, not the full snapshot — next turn's observe
@@ -1029,7 +1275,9 @@ export class BrowserAgent {
1029
1275
  }
1030
1276
  // -- 8. Post-action verification --
1031
1277
  if (expectedEffect && !turn.error) {
1032
- const verifyResult = await this.verifyEffect(expectedEffect, state);
1278
+ const verifyStartedAt = Date.now();
1279
+ this.bus.emitNow({ type: 'verify-started', runId, turn: i, expectedEffect });
1280
+ const verifyResult = await this.verifyEffect(expectedEffect, state, action.action);
1033
1281
  turn.verified = verifyResult.verified;
1034
1282
  if (!verifyResult.verified) {
1035
1283
  turn.verificationFailure = verifyResult.reason;
@@ -1040,6 +1288,24 @@ export class BrowserAgent {
1040
1288
  else if (this.config.debug) {
1041
1289
  console.log(`[Runner] Verification passed`);
1042
1290
  }
1291
+ this.bus.emitNow({
1292
+ type: 'verify-completed',
1293
+ runId,
1294
+ turn: i,
1295
+ verified: verifyResult.verified,
1296
+ ...(verifyResult.reason ? { reason: verifyResult.reason } : {}),
1297
+ durationMs: Date.now() - verifyStartedAt,
1298
+ });
1299
+ }
1300
+ else if (!turn.error
1301
+ && !this.cachedPostState
1302
+ && (action.action === 'wait' || action.action === 'scroll')
1303
+ && executedActions.length === 1) {
1304
+ // Pure wait/scroll with no expectedEffect: the ARIA tree didn't
1305
+ // structurally change. Reuse preActionState as the cached post-state
1306
+ // so the next loop iteration's observe is skipped. The driver's
1307
+ // refMap is still valid because no observe has reset it.
1308
+ this.cachedPostState = state;
1043
1309
  }
1044
1310
  if (executedActions.length > 1) {
1045
1311
  turn.executedActions = executedActions;
@@ -1047,6 +1313,12 @@ export class BrowserAgent {
1047
1313
  turn.durationMs = Date.now() - turnStart;
1048
1314
  turns.push(turn);
1049
1315
  this.onTurn?.(turn);
1316
+ this.bus.emitNow({ type: 'turn-completed', runId, turn: i, turnArtifact: turn });
1317
+ // Stash the expectedEffect so the NEXT turn's cache key includes it.
1318
+ // The post-action page state depends on what the agent claimed would
1319
+ // happen, so two turns with the same snapshot but different last
1320
+ // effects might warrant different decisions.
1321
+ lastEffectForCacheKey = expectedEffect ?? '';
1050
1322
  }
1051
1323
  catch (err) {
1052
1324
  runState.recordError();
@@ -1153,9 +1425,28 @@ export class BrowserAgent {
1153
1425
  * - "text should appear" -> check snapshot
1154
1426
  * - Generic text match -> check if text appears in snapshot
1155
1427
  */
1156
- async verifyEffect(expectedEffect, preActionState) {
1157
- await new Promise(r => setTimeout(r, 100));
1158
- const postState = await this.driver.observe().catch(() => preActionState);
1428
+ async verifyEffect(expectedEffect, preActionState, actionType) {
1429
+ // Only pause for actions that mutate the page in flight (navigation,
1430
+ // clicks that may trigger XHR/route transitions, form submits). For
1431
+ // pure reads, scrolls, hovers, and waits the page state is already
1432
+ // settled by the time execute returns. The previous unconditional
1433
+ // 100ms wait was pure dead time on every turn.
1434
+ const needsSettleWait = actionType === 'click'
1435
+ || actionType === 'navigate'
1436
+ || actionType === 'press'
1437
+ || actionType === 'select';
1438
+ // Kick observe off immediately and let the settle wait race against it.
1439
+ // observe() polls waitForLoadState internally, so the 50ms settle is
1440
+ // really only there to let click handlers schedule their first XHR; we
1441
+ // don't need to *block* on it before starting observe.
1442
+ const observePromise = this.driver.observe().catch(() => preActionState);
1443
+ if (needsSettleWait) {
1444
+ await Promise.all([
1445
+ observePromise,
1446
+ new Promise(r => setTimeout(r, 50)),
1447
+ ]);
1448
+ }
1449
+ const postState = await observePromise;
1159
1450
  this.cachedPostState = postState;
1160
1451
  return verifyExpectedEffect({
1161
1452
  expectedEffect,