e2e 0.21.1 → 0.21.2

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 (133) hide show
  1. package/README.md +2 -0
  2. package/dist/.build.json +1 -1
  3. package/dist/agent/act-validation.js +1 -0
  4. package/dist/agent/act.js +7 -3
  5. package/dist/agent/action-dispatcher.js +30 -8
  6. package/dist/agent/actions.d.ts +2 -0
  7. package/dist/agent/actions.js +5 -2
  8. package/dist/agent/error.js +1 -0
  9. package/dist/agent/executor.d.ts +14 -6
  10. package/dist/agent/index.js +5 -1
  11. package/dist/agent/invocation.d.ts +3 -0
  12. package/dist/agent/model/sdk.d.ts +5 -0
  13. package/dist/agent/model/sdk.js +18 -1
  14. package/dist/agent/observation.js +1 -0
  15. package/dist/agent/primitives.js +14 -3
  16. package/dist/agent/replay.js +9 -3
  17. package/dist/agent/step-cache.d.ts +3 -2
  18. package/dist/agent/step-cache.js +7 -4
  19. package/dist/agent/tool-loop.js +4 -1
  20. package/dist/cache/context.js +1 -1
  21. package/dist/cache/recorder.d.ts +2 -1
  22. package/dist/cache/recorder.js +11 -2
  23. package/dist/cache/trace-fields.d.ts +10 -0
  24. package/dist/cache/trace-fields.js +17 -0
  25. package/dist/cache/trace.d.ts +9 -1
  26. package/dist/cache/trace.js +26 -7
  27. package/dist/cli/feedback.js +2 -1
  28. package/dist/cli/init/sibling-versions.json +1 -1
  29. package/dist/collect/select.js +12 -2
  30. package/dist/config/agent.d.ts +1 -1
  31. package/dist/config/diagnose.js +9 -3
  32. package/dist/config/esm-hooks.js +3 -2
  33. package/dist/config/resolve.js +3 -2
  34. package/dist/config/typescript.js +52 -2
  35. package/dist/engine/contract.d.ts +3 -2
  36. package/dist/engine/manifest.js +2 -2
  37. package/dist/internal/errors.js +5 -2
  38. package/dist/internal/source.js +2 -0
  39. package/dist/internal/unserved-model.d.ts +5 -0
  40. package/dist/internal/unserved-model.js +22 -0
  41. package/dist/locator/screen.js +1 -2
  42. package/dist/oauth/copilot.js +23 -5
  43. package/dist/oauth/device-code.js +35 -19
  44. package/dist/oauth/errors.d.ts +3 -0
  45. package/dist/oauth/errors.js +26 -11
  46. package/dist/oauth/providers/github-copilot.d.ts +4 -0
  47. package/dist/oauth/providers/github-copilot.js +64 -16
  48. package/dist/oauth/providers/openai.d.ts +2 -2
  49. package/dist/oauth/providers/openai.js +47 -19
  50. package/dist/oauth/providers/opencode-console.js +36 -15
  51. package/dist/oauth/providers/xai.js +18 -9
  52. package/dist/oauth/token-endpoint.d.ts +15 -6
  53. package/dist/oauth/token-endpoint.js +18 -6
  54. package/dist/report/code-frame.js +9 -3
  55. package/dist/report/failure-text.d.ts +10 -2
  56. package/dist/report/failure-text.js +24 -8
  57. package/dist/report/list-model.d.ts +13 -0
  58. package/dist/report/list-steps.d.ts +6 -1
  59. package/dist/report/list-steps.js +29 -0
  60. package/dist/report/list.d.ts +1 -0
  61. package/dist/report/list.js +16 -7
  62. package/dist/report/live-window.js +2 -1
  63. package/dist/report/markdown.js +1 -1
  64. package/dist/run/fixtures.js +2 -0
  65. package/dist/run/host-trace.d.ts +3 -2
  66. package/dist/run/host-trace.js +2 -1
  67. package/dist/run/last-run.d.ts +9 -0
  68. package/dist/run/last-run.js +28 -0
  69. package/dist/run/open-session.d.ts +3 -1
  70. package/dist/run/open-session.js +1 -1
  71. package/dist/run/runner.js +6 -2
  72. package/dist/run/sessions.js +3 -3
  73. package/dist/run/standalone-attempt.d.ts +2 -1
  74. package/dist/run/standalone-attempt.js +3 -1
  75. package/dist/run/steps.d.ts +3 -1
  76. package/dist/run/units.d.ts +8 -0
  77. package/dist/run/units.js +14 -0
  78. package/dist/run/worker-models.d.ts +2 -0
  79. package/dist/run/worker-models.js +7 -0
  80. package/dist/types.d.ts +2 -2
  81. package/docs/authentication.mdx +26 -23
  82. package/docs/browser.mdx +7 -4
  83. package/docs/bug-bash.mdx +2 -2
  84. package/docs/cache.mdx +6 -1
  85. package/docs/ci/bitrise.mdx +1 -1
  86. package/docs/ci/codemagic.mdx +2 -2
  87. package/docs/ci/eas-workflows.mdx +1 -1
  88. package/docs/coding-agents.mdx +7 -35
  89. package/docs/debugging.mdx +24 -36
  90. package/docs/executors.mdx +1 -1
  91. package/docs/github.mdx +2 -1
  92. package/docs/integrations/eas.mdx +1 -1
  93. package/docs/integrations/index.mdx +6 -0
  94. package/docs/integrations/lightpanda.mdx +192 -0
  95. package/docs/integrations/testmu.mdx +152 -0
  96. package/docs/mcp.mdx +123 -0
  97. package/docs/migrate/detox.mdx +1 -1
  98. package/docs/migrate/maestro.mdx +101 -105
  99. package/docs/migrate/playwright.mdx +65 -70
  100. package/docs/mobile.mdx +18 -3
  101. package/docs/models.mdx +4 -2
  102. package/docs/quickstart.mdx +7 -0
  103. package/docs/reference/agent.mdx +369 -277
  104. package/docs/reference/app.mdx +141 -32
  105. package/docs/reference/cli.mdx +674 -558
  106. package/docs/reference/config.mdx +1406 -752
  107. package/docs/reference/engine.mdx +1062 -256
  108. package/docs/reference/environment.mdx +156 -84
  109. package/docs/reference/errors.mdx +106 -72
  110. package/docs/reference/expect.mdx +752 -372
  111. package/docs/reference/mcp.mdx +366 -141
  112. package/docs/reference/mobile.mdx +844 -403
  113. package/docs/reference/reporters.mdx +312 -106
  114. package/docs/reference/runner.mdx +373 -174
  115. package/docs/reference/screen.mdx +931 -398
  116. package/docs/reference/security.mdx +106 -40
  117. package/docs/reference/test.mdx +626 -273
  118. package/docs/reference/web.mdx +956 -533
  119. package/docs/security.mdx +6 -5
  120. package/docs/snippets/model-provider.jsx +763 -0
  121. package/docs/snippets/setup-prompt-card.jsx +22 -0
  122. package/docs/snippets/show-more.jsx +22 -0
  123. package/docs/snippets/video-card.jsx +34 -0
  124. package/docs/snippets/video.jsx +25 -0
  125. package/docs/subscriptions.mdx +10 -3
  126. package/docs/telemetry.mdx +39 -39
  127. package/package.json +3 -2
  128. package/schema/report-v1.schema.json +6 -0
  129. package/skills/e2e/references/agent.md +8 -3
  130. package/skills/e2e/references/debugging.md +1 -1
  131. package/skills/e2e/references/mcp.md +1 -1
  132. package/skills/e2e/references/setup.md +12 -3
  133. package/skills/e2e/references/writing-tests.md +1 -1
package/README.md CHANGED
@@ -58,10 +58,12 @@ and Flutter, each a standalone project with a passing suite.
58
58
  | [`@e2e-dev/mobile`](https://www.npmjs.com/package/@e2e-dev/mobile) | iOS and Android engine: simulators and emulators through agent-device. |
59
59
  | [`@e2e-dev/github`](https://www.npmjs.com/package/@e2e-dev/github) | Reporter that posts results as a pull request comment. |
60
60
  | [`@e2e-dev/kernel`](https://www.npmjs.com/package/@e2e-dev/kernel) | Kernel hosted browsers for the web engine. |
61
+ | [`@e2e-dev/lightpanda`](https://www.npmjs.com/package/@e2e-dev/lightpanda) | Lightpanda, the open-source headless browser, started per run for the web engine. |
61
62
  | [`@e2e-dev/limrun`](https://www.npmjs.com/package/@e2e-dev/limrun) | Limrun hosted iOS simulators and Android emulators for mobile tests. |
62
63
  | [`@e2e-dev/eas`](https://www.npmjs.com/package/@e2e-dev/eas) | EAS Simulators hosted iOS simulators and Android emulators for the mobile engine. |
63
64
  | [`@e2e-dev/smol`](https://www.npmjs.com/package/@e2e-dev/smol) | smol machines browsers for the web engine: by default, each attempt branches a warm Chromium microVM. |
64
65
  | [`@e2e-dev/decision`](https://e2e.tester.army/docs/decision-models) | Decision-model executors for bounded semantic actions and assertions. |
66
+ | [`@e2e-dev/testmu`](https://www.npmjs.com/package/@e2e-dev/testmu) | TestMu AI (formerly LambdaTest) hosted Android emulators, iOS simulators, and real devices for the mobile engine. |
65
67
 
66
68
  ## Documentation
67
69
 
package/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.21.1"}
1
+ {"version":"0.21.2"}
@@ -85,6 +85,7 @@ export function validateParams(params) {
85
85
  throw new TestError('INVALID_ARGUMENT', `agent.act params are ${bytes} canonical bytes; the maximum is ${MAX_PARAMS_BYTES}`);
86
86
  }
87
87
  return {
88
+ // oxlint-disable-next-line e2e/no-unchecked-json -- canonical is this function's own JSON.stringify of a plain object validateJsonValue just checked
88
89
  projected: JSON.parse(canonical),
89
90
  secrets: found.secrets,
90
91
  templates: found.templates,
package/dist/agent/act.js CHANGED
@@ -28,7 +28,7 @@ import { resolveBoundedBudget, resolveTimeout } from './call-options.js';
28
28
  import { RUNTIME_CODES } from './executor.js';
29
29
  import { projectPriorSteps, serializeLedger } from './ledger.js';
30
30
  import { tokenUpperBound } from './model/adapter.js';
31
- import { instantiateLanguageModel } from './model/sdk.js';
31
+ import { instantiateLanguageModel, isUnservedModelError, unservedModelError } from './model/sdk.js';
32
32
  import { ObservationFeed } from './observation-feed.js';
33
33
  import { recordPolicyEvent } from './phases.js';
34
34
  import { appLocation } from '../cache/route.js';
@@ -111,7 +111,10 @@ agent = runtime.select(raw.agent)) {
111
111
  verdict = await dispatch.run();
112
112
  }
113
113
  catch (cause) {
114
- throw dispatch.settleThrown(toAgentError(cause));
114
+ const error = unservedModelError(cause) ?? toAgentError(cause);
115
+ if (isUnservedModelError(error))
116
+ runtime.reportUnservedModel(error);
117
+ throw dispatch.settleThrown(error);
115
118
  }
116
119
  const evidence = await dispatch.captureEvidence();
117
120
  dispatch.settle(validateVerdict(verdict, agent.executor.name), evidence);
@@ -309,7 +312,8 @@ class ActDispatch {
309
312
  }
310
313
  /** Hands the settled outcome to the cache session, which stages, evicts, or does nothing. */
311
314
  async conclude(outcome) {
312
- await this.stepCache?.conclude(outcome, this.explanation);
315
+ const model = this.agent.config.model;
316
+ await this.stepCache?.conclude(outcome, this.explanation, model === undefined ? undefined : { provider: model.provider, model: model.id });
313
317
  }
314
318
  /**
315
319
  * Keeps a redacted screenshot as an assert step's evidence once the
@@ -9,6 +9,7 @@
9
9
  * nothing else in the step changes. Project tools run through the same budget
10
10
  * and queue as `runTool`.
11
11
  */
12
+ import { isKeyModifier, KEY_MODIFIERS } from '../engine/contract.js';
12
13
  import { asEngineError, TestError } from '../internal/errors.js';
13
14
  import { requireKey } from '../internal/keys.js';
14
15
  import { clampToViewport, requireFinitePoint, viewportShare } from '../internal/geometry.js';
@@ -16,6 +17,7 @@ import { resolveNavigationUrl } from '../internal/urls.js';
16
17
  import { PROJECT_TOOL_EVENT_PREFIX } from './action-names.js';
17
18
  import { containerKey, describeAction } from './actions.js';
18
19
  import { describePosition } from '../cache/relocate.js';
20
+ import { isTapActionName } from '../cache/trace.js';
19
21
  import { derivedReason } from './derived.js';
20
22
  import { AgentError } from './error.js';
21
23
  import { resolveScrollTarget } from './scroll-target.js';
@@ -68,6 +70,23 @@ const NODE_ACTION_KINDS = {
68
70
  hover: 'hover',
69
71
  scrollTo: 'scrollIntoView',
70
72
  };
73
+ /**
74
+ * The `modifiers` of a tap-family action, validated: distinct key modifiers,
75
+ * as the test API's `tap({ modifiers })` takes them. An empty list holds
76
+ * nothing and is dropped, so the recorded action stays clean.
77
+ */
78
+ function requireClickModifiers(value, verb) {
79
+ if (!Array.isArray(value) || !value.every(isKeyModifier)) {
80
+ throw new TestError('INVALID_ARGUMENT', `${verb}() modifiers must be an array of ${KEY_MODIFIERS.join(', ')}`);
81
+ }
82
+ const modifiers = [];
83
+ for (const modifier of value) {
84
+ if (modifiers.includes(modifier))
85
+ throw new TestError('INVALID_ARGUMENT', `${verb}() names a modifier twice`);
86
+ modifiers.push(modifier);
87
+ }
88
+ return modifiers.length === 0 ? undefined : modifiers;
89
+ }
71
90
  export class ActionDispatcher {
72
91
  runtime;
73
92
  accounting;
@@ -90,10 +109,10 @@ export class ActionDispatcher {
90
109
  this.queue = queue;
91
110
  this.options = options;
92
111
  this.actions = {
93
- tap: (target) => this.nodeVerb('tap', target),
94
- doubleTap: (target) => this.nodeVerb('doubleTap', target),
112
+ tap: (target, click) => this.nodeVerb('tap', target, click),
113
+ doubleTap: (target, click) => this.nodeVerb('doubleTap', target, click),
95
114
  longPress: (target) => this.nodeVerb('longPress', target),
96
- secondaryTap: (target) => this.nodeVerb('secondaryTap', target),
115
+ secondaryTap: (target, click) => this.nodeVerb('secondaryTap', target, click),
97
116
  hover: (target) => this.nodeVerb('hover', target),
98
117
  type: (target, value) => this.type(target, value),
99
118
  typeSecret: (target, name) => this.typeSecret(target, name),
@@ -167,8 +186,9 @@ export class ActionDispatcher {
167
186
  });
168
187
  }
169
188
  /** One node verb that carries nothing but its target: a tap or one of its variants, a hover, a scroll into view. */
170
- nodeVerb(name, target) {
171
- return this.commitTargeted(NODE_ACTION_KINDS[name], target, (node) => this.performNode(name, node));
189
+ nodeVerb(name, target, options) {
190
+ const modifiers = options?.modifiers === undefined ? undefined : requireClickModifiers(options.modifiers, name);
191
+ return this.commitTargeted(NODE_ACTION_KINDS[name], target, (node) => this.performNode(name, node, modifiers));
172
192
  }
173
193
  /**
174
194
  * Taps or hovers one viewport point, routed onto the tree in queue order
@@ -239,9 +259,11 @@ export class ActionDispatcher {
239
259
  get verbs() {
240
260
  return this.runtime.target.verbs;
241
261
  }
242
- async performNode(name, node) {
243
- await this.session.perform(node.ref, { kind: NODE_ACTION_KINDS[name] }, this.accounting.actionOperation());
244
- return { name, node };
262
+ async performNode(name, node, modifiers) {
263
+ const kind = NODE_ACTION_KINDS[name];
264
+ const action = modifiers !== undefined && isTapActionName(kind) ? { kind, modifiers } : { kind };
265
+ await this.session.perform(node.ref, action, this.accounting.actionOperation());
266
+ return { name, node, ...(modifiers === undefined ? {} : { modifiers }) };
245
267
  }
246
268
  /**
247
269
  * Sets a checkbox, switch, or radio to a state. The engine's `check` and
@@ -11,11 +11,13 @@
11
11
  import type { ViewportPoint, ViewportSize } from '../engine/surface.ts';
12
12
  import { type NodeRedaction, type RedactedNode } from './observation.ts';
13
13
  import { type NodeActionName, type PointActionName, type TraceTargetDescriptor, type TracePosition } from '../cache/trace.ts';
14
+ import type { KeyModifier } from '../engine/contract.ts';
14
15
  import type { ScrollDirection } from '../types.ts';
15
16
  /** One committed grammar action, addressed by the node it actually ran against. */
16
17
  export type RecordableAction = ({
17
18
  readonly name: NodeActionName;
18
19
  readonly node: RedactedNode;
20
+ readonly modifiers?: readonly KeyModifier[];
19
21
  } & Placement) | ({
20
22
  readonly name: 'type';
21
23
  readonly node: RedactedNode;
@@ -119,8 +119,11 @@ export function describeAction(action, redaction) {
119
119
  const where = describeForSummary(target);
120
120
  const safe = (value) => quote(redaction.redact(sanitizeText(value)));
121
121
  const prose = (() => {
122
- if (isNodeAction(action))
123
- return NODE_ACTION_PROSE[action.name](where);
122
+ if (isNodeAction(action)) {
123
+ const said = NODE_ACTION_PROSE[action.name](where);
124
+ const held = 'modifiers' in action ? action.modifiers : undefined;
125
+ return held === undefined || held.length === 0 ? said : `${said} with ${held.join('+')} held`;
126
+ }
124
127
  switch (action.name) {
125
128
  case 'type':
126
129
  return `type ${safe(action.value)} into ${where}`;
@@ -114,6 +114,7 @@ export function toAgentError(cause) {
114
114
  export function isAgentError(value) {
115
115
  return (value instanceof Error &&
116
116
  AGENT_ERROR_MARKER in value &&
117
+ 'code' in value &&
117
118
  typeof value.code === 'string' &&
118
119
  value.code in CATEGORY_BY_CODE);
119
120
  }
@@ -18,7 +18,7 @@
18
18
  */
19
19
  import type { ObservationPixels, SemanticNode, ViewportPoint, ViewportSize } from '../engine/surface.ts';
20
20
  import type { VisionDegradation, StepTurn } from '../run/steps.ts';
21
- import type { AgentErrorCode, JsonValue, ModelInstance, ProviderOptions, ScrollDirection, Secret, VisionMode } from '../types.ts';
21
+ import type { AgentErrorCode, ClickOptions, JsonValue, ModelInstance, ProviderOptions, ScrollDirection, Secret, VisionMode } from '../types.ts';
22
22
  import { type AgentError } from './error.ts';
23
23
  /**
24
24
  * One step handed to an executor. `act` plans and executes a flow; `assert`
@@ -155,13 +155,18 @@ export interface ExecutorTarget {
155
155
  * fresh `observe()`.
156
156
  */
157
157
  export interface ExecutorActions {
158
- tap(target: ExecutorTarget): Promise<void>;
159
- /** Double-taps one node (`doubleTap` in `target.verbs`). */
160
- doubleTap(target: ExecutorTarget): Promise<void>;
158
+ /**
159
+ * Taps one node. `options.modifiers` holds keys for the click, as a
160
+ * Shift-click extends a selection; needs `target.tapModifiers`,
161
+ * `UNSUPPORTED_CAPABILITY` otherwise.
162
+ */
163
+ tap(target: ExecutorTarget, options?: Pick<ClickOptions, 'modifiers'>): Promise<void>;
164
+ /** Double-taps one node (`doubleTap` in `target.verbs`). Takes the tap's `options.modifiers`. */
165
+ doubleTap(target: ExecutorTarget, options?: Pick<ClickOptions, 'modifiers'>): Promise<void>;
161
166
  /** Presses one node and holds for the engine's default duration (`longPress` in `target.verbs`). */
162
167
  longPress(target: ExecutorTarget): Promise<void>;
163
- /** A right-click on a pointer surface (`secondaryTap` in `target.verbs`). */
164
- secondaryTap(target: ExecutorTarget): Promise<void>;
168
+ /** A right-click on a pointer surface (`secondaryTap` in `target.verbs`). Takes the tap's `options.modifiers`. */
169
+ secondaryTap(target: ExecutorTarget, options?: Pick<ClickOptions, 'modifiers'>): Promise<void>;
165
170
  /** Moves the pointer over one node without pressing (`hover` in `target.verbs`). */
166
171
  hover(target: ExecutorTarget): Promise<void>;
167
172
  type(target: ExecutorTarget, value: string): Promise<void>;
@@ -370,11 +375,14 @@ export interface StepExecutorContext {
370
375
  * The target this step runs on. Tool packs scope themselves by its platform;
371
376
  * `verbs` is the subset of the action grammar the engine declared, so an
372
377
  * executor offers a model exactly the vocabulary the surface can honor.
378
+ * `tapModifiers` is true when `tap`, `doubleTap`, and `secondaryTap` accept
379
+ * `modifiers`.
373
380
  */
374
381
  readonly target: {
375
382
  readonly name: string;
376
383
  readonly platform: string;
377
384
  readonly verbs: ReadonlySet<ExecutorVerb>;
385
+ readonly tapModifiers: boolean;
378
386
  };
379
387
  /**
380
388
  * Present when a cached replay ran part of this step before handing it
@@ -14,6 +14,7 @@ import { rejectUnknownOptions } from '../internal/options.js';
14
14
  import { describeIssue, requireStandardSchema } from '../internal/standard-schema.js';
15
15
  import { sleep } from '../internal/time.js';
16
16
  import { AgentError, isAgentError, toAgentError } from './error.js';
17
+ import { isUnservedModelError, unservedModelError } from './model/sdk.js';
17
18
  import { resolveBoundedBudget, resolveTimeout } from './call-options.js';
18
19
  import { runActStep, runAssertStep } from './act.js';
19
20
  import { observationShape } from './observation.js';
@@ -82,7 +83,10 @@ export function createAgentFixture(runtime) {
82
83
  return await body(invocation);
83
84
  }
84
85
  catch (cause) {
85
- throw toAgentError(cause);
86
+ const error = unservedModelError(cause) ?? toAgentError(cause);
87
+ if (isUnservedModelError(error))
88
+ runtime.reportUnservedModel(error);
89
+ throw error;
86
90
  }
87
91
  finally {
88
92
  invocation.finish();
@@ -19,6 +19,7 @@ import type { ArtifactSink } from '../run/fixtures.ts';
19
19
  import type { SecretExposure } from '../run/secrecy.ts';
20
20
  import type { StepRecord, StepRecorder } from '../run/steps.ts';
21
21
  import type { VisionMode } from '../types.ts';
22
+ import { AgentError } from './error.ts';
22
23
  import type { ExecutorAttempt, StepExecutor } from './executor.ts';
23
24
  import { type ModelAdapter } from './model/adapter.ts';
24
25
  import { type AgentObservation } from './observation.ts';
@@ -66,6 +67,8 @@ export interface AgentContext {
66
67
  * the run's. An unknown name is `INVALID_ARGUMENT`.
67
68
  */
68
69
  readonly select: (name: string | undefined) => AgentSelection;
70
+ /** Reports a model the provider refused, met by a step's call; the run stops once over it. */
71
+ readonly reportUnservedModel: (error: AgentError) => void;
69
72
  readonly config: ResolvedConfig;
70
73
  /** The target this attempt runs on. */
71
74
  readonly target: StepExecutorContext['target'];
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { type ResolvedModel } from '../../config/agent.ts';
10
10
  import { type SdkLanguageModel } from '../ai-sdk.ts';
11
+ import { AgentError } from '../error.ts';
11
12
  import { type ModelAdapter } from './adapter.ts';
12
13
  /**
13
14
  * Provider transport retries; distinct from runner-owned model-call budget.
@@ -59,6 +60,10 @@ export declare function cacheTokenFields(usage: SdkUsage | undefined): {
59
60
  * the key.
60
61
  */
61
62
  export declare function readCost(metadata: Readonly<Record<string, Readonly<Record<string, unknown>>>> | undefined): number | undefined;
63
+ /** A marked failure (`internal/unserved-model.ts`) as the run-stopping `MODEL_UNAVAILABLE` the fixture reports once, or that error itself; undefined for any other failure. */
64
+ export declare function unservedModelError(cause: unknown): AgentError | undefined;
65
+ /** True for an error `unservedModelError` built, in this realm or another. */
66
+ export declare function isUnservedModelError(value: unknown): value is AgentError;
62
67
  /**
63
68
  * The remedy a provider failure points at, when its shape names one: a
64
69
  * rejected credential, or a conversation the provider kept no state for.
@@ -7,11 +7,12 @@
7
7
  * provider-independent.
8
8
  */
9
9
  import { providerFailureMessage, withHint } from '../../internal/errors.js';
10
+ import { markUnservedModel, unservedModelExplanation } from '../../internal/unserved-model.js';
10
11
  import { missingModelError } from '../../config/agent.js';
11
12
  import { aiSdk, asSdkLanguageModel, loadAiSdk } from '../ai-sdk.js';
12
13
  import { MODEL_REQUEST_HEADERS } from '../../internal/client-identity.js';
13
14
  import { packageVersion } from '../../internal/package-version.js';
14
- import { AgentError } from '../error.js';
15
+ import { AgentError, isAgentError } from '../error.js';
15
16
  import { isContextOverflow } from './overflow.js';
16
17
  import { providerHints } from './provider-hints.js';
17
18
  import { withStallGuard } from './stall.js';
@@ -238,11 +239,27 @@ function translateModelError(rawCause, issue, signal) {
238
239
  if (isContextOverflow(cause)) {
239
240
  return new AgentError('CONTEXT_OVERFLOW', `the model request exceeded the context window: ${providerFailureMessage(cause)}`, { cause });
240
241
  }
242
+ const unserved = unservedModelError(cause);
243
+ if (unserved !== undefined)
244
+ return unserved;
241
245
  if (APICallError.isInstance(cause)) {
242
246
  return new AgentError('MODEL_PROVIDER_FAILED', withHint(`model provider failed: ${providerFailureMessage(cause)}`, failureHint(cause)), { cause });
243
247
  }
244
248
  return new AgentError('MODEL_PROVIDER_FAILED', `model provider failed: ${providerFailureMessage(cause)}`, { cause });
245
249
  }
250
+ /** A marked failure (`internal/unserved-model.ts`) as the run-stopping `MODEL_UNAVAILABLE` the fixture reports once, or that error itself; undefined for any other failure. */
251
+ export function unservedModelError(cause) {
252
+ if (isUnservedModelError(cause))
253
+ return cause;
254
+ const explanation = unservedModelExplanation(cause);
255
+ if (explanation === undefined)
256
+ return undefined;
257
+ return markUnservedModel(new AgentError('MODEL_UNAVAILABLE', explanation, { cause }), explanation);
258
+ }
259
+ /** True for an error `unservedModelError` built, in this realm or another. */
260
+ export function isUnservedModelError(value) {
261
+ return isAgentError(value) && unservedModelExplanation(value) !== undefined;
262
+ }
246
263
  /**
247
264
  * The remedy a provider failure points at, when its shape names one: a
248
265
  * rejected credential, or a conversation the provider kept no state for.
@@ -145,6 +145,7 @@ export function redactNode(node, redaction) {
145
145
  }
146
146
  if (node.children !== undefined)
147
147
  redacted['children'] = node.children.map((child) => redactNode(child, redaction));
148
+ // oxlint-disable-next-line e2e/no-chained-type-assertions -- mints the phantom REDACTED brand once every field passed NODE_FIELDS
148
149
  return redacted;
149
150
  }
150
151
  /**
@@ -9,6 +9,8 @@
9
9
  * the module is safe to import from a config that never calls a model.
10
10
  */
11
11
  import { z } from 'zod';
12
+ import { isTapActionName } from '../cache/trace.js';
13
+ import { KEY_MODIFIERS } from '../engine/contract.js';
12
14
  import { codedMessage, hasCause, TestError } from '../internal/errors.js';
13
15
  import { isRuntimeHardStop, BLOCKABLE_CODES } from './executor.js';
14
16
  import { cacheTokenFields, readCost } from './model/sdk.js';
@@ -231,13 +233,22 @@ export function createGrammarTools(context, options = {}) {
231
233
  execute: () => inOrder(() => guard(() => present('Observed.', { expectChange: false, keyboardNote: false }))),
232
234
  }),
233
235
  };
236
+ const modifiers = z
237
+ .array(z.enum(KEY_MODIFIERS))
238
+ .optional()
239
+ .describe('Keys held while the pointer clicks: "Shift", "Control", "Alt", "Meta", or "ControlOrMeta". A Shift-click extends a selection, a Control-click (Meta on macOS) toggles one item.');
234
240
  for (const { verb, tool, lead, description, expectChange } of NODE_VERBS) {
235
241
  if (!verbs.has(verb))
236
242
  continue;
243
+ const holdable = context.target.tapModifiers && isTapActionName(verb);
237
244
  tools[tool] = screenTool({
238
- description,
239
- inputSchema: z.object({ target }),
240
- execute: ({ target: id }) => acting({ done: `${lead} #${id}.`, attempt: `${tool} #${id}` }, () => context.actions[verb]({ id }), expectChange === undefined ? {} : { expectChange }),
245
+ description: holdable ? `${description} Pass modifiers to hold keys for the click.` : description,
246
+ inputSchema: holdable ? z.object({ target, modifiers }) : z.object({ target }),
247
+ execute: (input) => {
248
+ const { target: id, modifiers: held } = input;
249
+ const run = () => isTapActionName(verb) && held !== undefined ? context.actions[verb]({ id }, { modifiers: held }) : context.actions[verb]({ id });
250
+ return acting({ done: `${lead} #${id}.`, attempt: `${tool} #${id}` }, run, expectChange === undefined ? {} : { expectChange });
251
+ },
241
252
  });
242
253
  }
243
254
  // scroll_to reaches a node the screen lists (by id, through the engine's
@@ -12,7 +12,7 @@
12
12
  * divergence.
13
13
  */
14
14
  import { isRelocatableDescriptor, MAIN_LIST_SHARE, relocateDescriptor } from '../cache/relocate.js';
15
- import { isNodeAction } from '../cache/trace.js';
15
+ import { isNodeAction, isTapActionName } from '../cache/trace.js';
16
16
  import { hasCause } from '../internal/errors.js';
17
17
  import { containsPoint } from '../internal/geometry.js';
18
18
  import { sleep } from '../internal/time.js';
@@ -23,8 +23,14 @@ const RETRY_DELAYS_MS = [100, 300, 600, 1_000, 3_000];
23
23
  /** Ceiling on one poll's total wait, inside whatever the deadline allows. */
24
24
  const RETRY_TIMEOUT_MS = 15_000;
25
25
  function planCall(action, actions) {
26
- if (isNodeAction(action))
27
- return { kind: 'targeted', descriptor: action.target, invoke: (t) => actions[action.name](t) };
26
+ if (isNodeAction(action)) {
27
+ const { name, modifiers } = action;
28
+ return {
29
+ kind: 'targeted',
30
+ descriptor: action.target,
31
+ invoke: (t) => (isTapActionName(name) && modifiers !== undefined ? actions[name](t, { modifiers }) : actions[name](t)),
32
+ };
33
+ }
28
34
  switch (action.name) {
29
35
  case 'tool':
30
36
  return { kind: 'gap', ...(action.derived === undefined ? {} : { derived: action.derived }) };
@@ -14,7 +14,7 @@
14
14
  import type { AgentCacheContext } from '../cache/context.ts';
15
15
  import { type CacheAgentIdentity } from '../cache/identity.ts';
16
16
  import { type ParamTemplate } from '../cache/template.ts';
17
- import { type DerivedReason } from '../cache/trace.ts';
17
+ import { type DerivedReason, type RecordedModel } from '../cache/trace.ts';
18
18
  import type { StepCacheInfo } from '../run/steps.ts';
19
19
  import type { JsonValue } from '../types.ts';
20
20
  import type { RecordableAction } from './actions.ts';
@@ -71,6 +71,7 @@ export declare class StepTraceSession {
71
71
  private readonly options;
72
72
  private readonly redaction;
73
73
  private info;
74
+ private recordedBy;
74
75
  private prefix;
75
76
  private startPath;
76
77
  private startedMs;
@@ -170,7 +171,7 @@ export declare class StepTraceSession {
170
171
  * that diverged hands off again next run, where a model that answers
171
172
  * re-records it.
172
173
  */
173
- conclude(outcome: StepOutcome, verdictSummary: string | undefined): Promise<void>;
174
+ conclude(outcome: StepOutcome, verdictSummary: string | undefined, recordedBy?: RecordedModel): Promise<void>;
174
175
  /**
175
176
  * One store read, revalidated through the trace-1 framing whichever store
176
177
  * produced it: a custom store's entry can never be trusted more loosely
@@ -69,6 +69,7 @@ export class StepTraceSession {
69
69
  options;
70
70
  redaction;
71
71
  info;
72
+ recordedBy;
72
73
  prefix;
73
74
  startPath;
74
75
  startedMs = Date.now();
@@ -117,7 +118,7 @@ export class StepTraceSession {
117
118
  }
118
119
  /** How the cache participated, for the step's report detail. */
119
120
  get cacheInfo() {
120
- return this.info;
121
+ return this.info === undefined || this.recordedBy === undefined ? this.info : { ...this.info, recordedBy: this.recordedBy };
121
122
  }
122
123
  /** The mid-step hand-off, when a replay diverged after real progress. */
123
124
  get replayedPrefix() {
@@ -238,7 +239,7 @@ export class StepTraceSession {
238
239
  * that diverged hands off again next run, where a model that answers
239
240
  * re-records it.
240
241
  */
241
- async conclude(outcome, verdictSummary) {
242
+ async conclude(outcome, verdictSummary, recordedBy) {
242
243
  const recorder = this.recorder;
243
244
  if (recorder === undefined)
244
245
  return;
@@ -263,7 +264,7 @@ export class StepTraceSession {
263
264
  });
264
265
  }
265
266
  else {
266
- const staged = await this.stage(recorder, verdictSummary);
267
+ const staged = await this.stage(recorder, verdictSummary, recordedBy);
267
268
  if (staged !== 'staged' && this.readEntryHit)
268
269
  await this.evict();
269
270
  else if (staged === 'no-change')
@@ -326,6 +327,7 @@ export class StepTraceSession {
326
327
  async replayEntry(entry) {
327
328
  const start = await this.captureStart('replay-start');
328
329
  const trace = entry.payload;
330
+ this.recordedBy = trace.recordedBy;
329
331
  if (!this.host.traceEligible) {
330
332
  this.info = this.missed('truncated', trace.actions.length);
331
333
  return undefined;
@@ -506,7 +508,7 @@ export class StepTraceSession {
506
508
  * does a step that changed nothing a replay could check, no node and no
507
509
  * route: a trace without its check would replay on mechanics alone.
508
510
  */
509
- async stage(recorder, verdictSummary) {
511
+ async stage(recorder, verdictSummary, recordedBy) {
510
512
  if (!this.host.traceEligible || this.startNodes === undefined || recorder.recordedCount === 0)
511
513
  return 'skipped';
512
514
  const observation = await probeScreen(this.host, 'held-still');
@@ -523,6 +525,7 @@ export class StepTraceSession {
523
525
  const trace = recorder.finalize({
524
526
  executor: this.options.executor,
525
527
  recordedFor: this.claim.step,
528
+ ...(recordedBy === undefined ? {} : { recordedBy }),
526
529
  summary: verdictSummary ?? 'step passed',
527
530
  ...(this.startPath === undefined ? {} : { startPath: this.startPath }),
528
531
  ...(endPath === undefined ? {} : { endPath }),
@@ -13,7 +13,7 @@
13
13
  import { asSdkLanguageModel, loadAiSdk } from './ai-sdk.js';
14
14
  import { MODEL_REQUEST_HEADERS } from '../internal/client-identity.js';
15
15
  import { ConfigurationError, providerFailureMessage, withHint } from '../internal/errors.js';
16
- import { failureHint, isAbort, TRANSPORT_RETRIES } from './model/sdk.js';
16
+ import { failureHint, isAbort, TRANSPORT_RETRIES, unservedModelError } from './model/sdk.js';
17
17
  import { isContextOverflow } from './model/overflow.js';
18
18
  import { isForcedToolCallMismatched, isForcedToolCallSkipped, isForcedToolChoiceDowngraded, isForcedToolChoiceRejected, } from './model/tool-choice.js';
19
19
  import { withStallGuard } from './model/stall.js';
@@ -220,6 +220,9 @@ class LoopRun {
220
220
  if (isContextOverflow(cause)) {
221
221
  throw new AgentError('CONTEXT_OVERFLOW', `the model request exceeded the context window${this.overflowNote ?? ''}: ${message}`, { cause });
222
222
  }
223
+ const unserved = unservedModelError(cause);
224
+ if (unserved !== undefined)
225
+ throw unserved;
223
226
  throw new AgentError('MODEL_PROVIDER_FAILED', withHint(`the model provider failed: ${message}`, failureHint(cause)), { cause });
224
227
  }
225
228
  }
@@ -30,7 +30,7 @@ async function holdsSameFlow(store, keyHash, trace) {
30
30
  }
31
31
  /** The part of a trace that decides what a replay does, as canonical JSON. */
32
32
  function flowOf(trace) {
33
- const { summary: _summary, endWaitMs: _endWaitMs, keyedBy: _keyedBy, actions, ...flow } = trace;
33
+ const { summary: _summary, endWaitMs: _endWaitMs, keyedBy: _keyedBy, recordedBy: _recordedBy, actions, ...flow } = trace;
34
34
  return canonicalJson({
35
35
  ...flow,
36
36
  actions: actions.map((action) => {
@@ -16,7 +16,7 @@
16
16
  * state. A poisoned trace still documents what happened; it never replays.
17
17
  */
18
18
  import { type RecordableAction } from '../agent/actions.ts';
19
- import { type ActionTrace, type DerivedReason, type TraceKeyContext, type TraceProvenance, type TraceTargetDescriptor } from './trace.ts';
19
+ import { type ActionTrace, type DerivedReason, type RecordedModel, type TraceKeyContext, type TraceProvenance, type TraceTargetDescriptor } from './trace.ts';
20
20
  export interface TraceRecorderOptions {
21
21
  /** The run's secret redactor; applied to every recorded string. */
22
22
  readonly redact: (text: string) => string;
@@ -61,6 +61,7 @@ export declare class TraceRecorder {
61
61
  };
62
62
  /** Test, target, and instruction digest the trace was recorded for. */
63
63
  readonly recordedFor: TraceProvenance;
64
+ readonly recordedBy?: RecordedModel;
64
65
  readonly summary: string;
65
66
  readonly startPath?: string;
66
67
  readonly endPath?: string;
@@ -18,6 +18,7 @@
18
18
  import { describeAction } from '../agent/actions.js';
19
19
  import { isRelocatableDescriptor } from './relocate.js';
20
20
  import { DESCRIPTOR_FIELDS, MAX_TRACE_ACTIONS, MAX_TRACE_DESCRIPTOR_CHARS, MAX_TRACE_END_WAIT_MS, MAX_TRACE_INPUT_CHARS, MAX_TRACE_SUMMARY_CHARS, isNodeAction, } from './trace.js';
21
+ import { readRecordedModel } from './trace-fields.js';
21
22
  import { bound } from '../internal/text.js';
22
23
  /** The summary a run-time value gap records; the prose a project-tool gap would carry for a tool of that name. */
23
24
  const DERIVED_GAP_SUMMARY = 'tool type (run-time value)';
@@ -71,6 +72,8 @@ export class TraceRecorder {
71
72
  finalize(conclusion) {
72
73
  if (this.actions.length === 0)
73
74
  return undefined;
75
+ const source = conclusion.recordedBy;
76
+ const recordedBy = source === undefined ? undefined : readRecordedModel({ provider: this.redact(source.provider), model: this.redact(source.model) }, MAX_TRACE_DESCRIPTOR_CHARS);
74
77
  const summary = bound(this.redact(conclusion.summary), MAX_TRACE_SUMMARY_CHARS);
75
78
  // Locations are replay preconditions, so they follow the input rule: a
76
79
  // path the redactor alters carried a secret (a token in a query string),
@@ -85,6 +88,7 @@ export class TraceRecorder {
85
88
  ...(conclusion.executor.version === undefined ? {} : { version: conclusion.executor.version }),
86
89
  },
87
90
  recordedFor: recordedProvenance(conclusion.recordedFor, this.redact),
91
+ ...(recordedBy === undefined ? {} : { recordedBy }),
88
92
  summary: summary.trim() === '' ? 'step passed' : summary,
89
93
  ...(startPath === undefined ? {} : { startPath }),
90
94
  ...(endPath === undefined ? {} : { endPath }),
@@ -123,8 +127,13 @@ export class TraceRecorder {
123
127
  return descriptor ?? { role: 'unknown' };
124
128
  };
125
129
  const requireTarget = () => require(target);
126
- if (isNodeAction(action))
127
- return { name: action.name, summary, target: requireTarget() };
130
+ if (isNodeAction(action)) {
131
+ const recorded = { name: action.name, summary, target: requireTarget() };
132
+ // A tap with held keys replays with them, so the trace keeps them.
133
+ if ('modifiers' in action && action.modifiers !== undefined)
134
+ return { ...recorded, modifiers: [...action.modifiers] };
135
+ return recorded;
136
+ }
128
137
  switch (action.name) {
129
138
  case 'check':
130
139
  return { name: 'check', summary, target: requireTarget(), checked: action.checked };
@@ -0,0 +1,10 @@
1
+ /** Scalar fields shared by trace readers and the recorder. */
2
+ /** The configured model identity attached to a recording. */
3
+ export interface RecordedModel {
4
+ readonly provider: string;
5
+ readonly model: string;
6
+ }
7
+ /** Nonblank text within the field's character budget, preserved verbatim. */
8
+ export declare function readBoundedText(value: unknown, maxChars: number): string | undefined;
9
+ /** Malformed optional model attribution never makes a recording unreadable. */
10
+ export declare function readRecordedModel(value: unknown, maxChars: number): RecordedModel | undefined;
@@ -0,0 +1,17 @@
1
+ /** Scalar fields shared by trace readers and the recorder. */
2
+ /** Nonblank text within the field's character budget, preserved verbatim. */
3
+ export function readBoundedText(value, maxChars) {
4
+ if (typeof value !== 'string' || value.trim() === '' || value.length > maxChars) {
5
+ return undefined;
6
+ }
7
+ return value;
8
+ }
9
+ /** Malformed optional model attribution never makes a recording unreadable. */
10
+ export function readRecordedModel(value, maxChars) {
11
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
12
+ return undefined;
13
+ const raw = value;
14
+ const provider = readBoundedText(raw['provider'], maxChars);
15
+ const model = readBoundedText(raw['model'], maxChars);
16
+ return provider === undefined || model === undefined ? undefined : { provider, model };
17
+ }