explorbot 0.4.7 → 0.4.9

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 (59) hide show
  1. package/boat/api-tester/src/ai/chief.ts +3 -1
  2. package/boat/api-tester/src/ai/curler.ts +4 -0
  3. package/boat/api-tester/src/cli.ts +1 -1
  4. package/boat/api-tester/src/config.ts +34 -26
  5. package/dist/boat/api-tester/src/ai/chief.js +3 -1
  6. package/dist/boat/api-tester/src/ai/curler.js +4 -0
  7. package/dist/boat/api-tester/src/cli.js +1 -1
  8. package/dist/boat/api-tester/src/config.js +32 -26
  9. package/dist/package.json +1 -1
  10. package/dist/rules/chief/general.md +2 -0
  11. package/dist/rules/researcher/pagination.md +1 -0
  12. package/dist/src/action.js +3 -2
  13. package/dist/src/ai/fisherman/tools.js +10 -4
  14. package/dist/src/ai/navigator.js +1 -4
  15. package/dist/src/ai/pilot.js +19 -18
  16. package/dist/src/ai/planner/session-dedup.d.ts +2 -1
  17. package/dist/src/ai/planner/session-dedup.js +18 -1
  18. package/dist/src/ai/planner.js +8 -4
  19. package/dist/src/ai/provider.js +18 -4
  20. package/dist/src/ai/rules.js +1 -0
  21. package/dist/src/ai/tester.js +1 -1
  22. package/dist/src/ai/tools.js +10 -2
  23. package/dist/src/commands/config-command.d.ts +2 -0
  24. package/dist/src/commands/config-command.js +8 -2
  25. package/dist/src/commands/init-command.js +6 -26
  26. package/dist/src/commands/sites-command.js +6 -1
  27. package/dist/src/config.d.ts +5 -4
  28. package/dist/src/config.js +25 -23
  29. package/dist/src/global-config.d.ts +6 -0
  30. package/dist/src/global-config.js +82 -7
  31. package/dist/src/utils/code-extractor.js +6 -2
  32. package/dist/src/utils/html.js +6 -0
  33. package/dist/src/utils/merge.d.ts +1 -0
  34. package/dist/src/utils/merge.js +11 -0
  35. package/docs/reference/commands.md +10 -2
  36. package/docs/reference/configuration.md +37 -4
  37. package/docs/superpowers/plans/2026-09-15-mdq-package.md +2029 -0
  38. package/docs/superpowers/specs/2026-09-14-mdq-package-design.md +397 -0
  39. package/package.json +1 -1
  40. package/rules/chief/general.md +2 -0
  41. package/rules/researcher/pagination.md +1 -0
  42. package/src/action.ts +3 -2
  43. package/src/ai/fisherman/tools.ts +10 -4
  44. package/src/ai/navigator.ts +1 -4
  45. package/src/ai/pilot.ts +19 -18
  46. package/src/ai/planner/session-dedup.ts +16 -2
  47. package/src/ai/planner.ts +8 -4
  48. package/src/ai/provider.ts +18 -3
  49. package/src/ai/rules.ts +1 -0
  50. package/src/ai/tester.ts +1 -1
  51. package/src/ai/tools.ts +9 -2
  52. package/src/commands/config-command.ts +9 -2
  53. package/src/commands/init-command.ts +6 -27
  54. package/src/commands/sites-command.ts +6 -1
  55. package/src/config.ts +28 -25
  56. package/src/global-config.ts +81 -6
  57. package/src/utils/code-extractor.ts +6 -2
  58. package/src/utils/html.ts +6 -0
  59. package/src/utils/merge.ts +13 -0
@@ -261,7 +261,9 @@ export class Chief extends ChiefBase {
261
261
  - Use real enum values discovered in the data
262
262
  - Each test MUST use DIFFERENT data — never reuse the same field values across tests
263
263
  - For "create" tests: base payload on a real record but change field values to create new unique data
264
- - For "update" tests: pick a real existing ID and modify specific fields
264
+ - Treat records and IDs from sample_data as read-only. Never update, patch, delete, archive, or otherwise mutate them
265
+ - For update/delete tests: the same scenario must first create its own target, then mutate only that target
266
+ - For negative or unsupported-method tests that could mutate data if accepted: create a scenario-owned target first; if that setup is impossible, do not send the destructive request
265
267
  - For tests needing parent references: use real _id field values from sample_data
266
268
  `);
267
269
  }
@@ -284,6 +284,10 @@ export class Curler {
284
284
  - Record important findings as you go
285
285
  - Be precise about what you expect vs what you observe
286
286
  - If a test requires data from another endpoint, use schemaFor to look it up before guessing
287
+ - Treat existing records, sample data, and IDs supplied by the plan as read-only
288
+ - Before PUT, PATCH, DELETE, archive, or another destructive request, create the target inside the current scenario and mutate only that target
289
+ - This also applies when testing an unsupported method: the server may unexpectedly accept it, so never probe destructively against pre-existing data
290
+ - If a scenario-owned target cannot be created, use stop rather than risking existing data
287
291
  </rules>
288
292
  `;
289
293
  }
@@ -40,7 +40,7 @@ export function createApiCommands(name = 'api'): Command {
40
40
  if (runOptions.endpoint && URL.canParse(runOptions.endpoint)) runOptions.baseEndpoint ||= runOptions.endpoint;
41
41
  try {
42
42
  const config = await parser.loadConfig(runOptions);
43
- console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
43
+ console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
44
44
  } catch (error) {
45
45
  console.error(error instanceof Error ? error.message : 'Unknown error');
46
46
  process.exit(1);
@@ -18,7 +18,8 @@ import {
18
18
  resolveOutputRoot,
19
19
  setOutputDir,
20
20
  } from '../../../src/config.ts';
21
- import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
21
+ import { type SiteRecord, findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from '../../../src/global-config.ts';
22
+ import { deepMerge } from '../../../src/utils/merge.ts';
22
23
 
23
24
  export type { AIConfig };
24
25
 
@@ -58,6 +59,7 @@ export class ApibotConfigParser {
58
59
  private config: ApibotConfig | null = null;
59
60
  private configPath: string | null = null;
60
61
  private site: SiteRecord | null = null;
62
+ private siteConfigPath: string | null = null;
61
63
 
62
64
  private constructor() {}
63
65
 
@@ -111,20 +113,22 @@ export class ApibotConfigParser {
111
113
  };
112
114
  }
113
115
 
114
- this.config = this.mergeWithDefaults(loadedConfig);
115
- this.applyEnvSpec(this.config.api);
116
- this.applyEnvHeaders(this.config.api);
117
- if (options?.baseEndpoint) this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
118
- await resolveConfigModels(this.config.ai);
119
- resolveLangfuse(this.config.ai);
120
- this.configPath = resolvedPath;
116
+ let config = this.mergeWithDefaults(loadedConfig);
117
+ this.applyEnvSpec(config.api);
118
+ this.applyEnvHeaders(config.api);
119
+ if (options?.baseEndpoint) config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
120
+ await resolveConfigModels(config.ai);
121
+ resolveLangfuse(config.ai);
121
122
  this.site = null;
123
+ this.siteConfigPath = null;
122
124
 
123
125
  if (isGlobalConfigPath(resolvedPath)) {
124
- this.enterGlobalMode(this.config, options?.endpoint);
126
+ config = await this.enterGlobalMode(config, options?.endpoint);
125
127
  }
126
128
 
127
- this.validateConfig(this.config);
129
+ this.validateConfig(config);
130
+ this.config = config;
131
+ this.configPath = resolvedPath;
128
132
  setOutputDir(this.getOutputDir());
129
133
 
130
134
  return this.config;
@@ -144,6 +148,10 @@ export class ApibotConfigParser {
144
148
  return this.configPath;
145
149
  }
146
150
 
151
+ getSiteConfigPath(): string | null {
152
+ return this.siteConfigPath;
153
+ }
154
+
147
155
  getOutputDir(): string {
148
156
  const config = this.getConfig();
149
157
  return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
@@ -207,17 +215,29 @@ export class ApibotConfigParser {
207
215
  api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
208
216
  }
209
217
 
210
- private enterGlobalMode(config: ApibotConfig, endpoint?: string): void {
218
+ private async enterGlobalMode(config: ApibotConfig, endpoint?: string): Promise<ApibotConfig> {
211
219
  const site = resolveSiteTarget(endpoint);
212
220
  this.site = registerSite(site.baseUrl);
213
221
 
222
+ const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
223
+ this.siteConfigPath = path;
224
+
225
+ const overrides: Partial<ApibotConfig> = {};
226
+ if (siteConfig.ai) overrides.ai = siteConfig.ai;
227
+ if (siteConfig.api) overrides.api = siteConfig.api;
228
+
229
+ const merged = deepMerge(config, overrides);
230
+ await resolveConfigModels(merged.ai);
231
+ resolveLangfuse(merged.ai);
232
+
214
233
  let baseEndpoint = site.baseUrl;
215
234
  const envUrl = process.env.EXPLORBOT_URL;
216
235
  if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl) baseEndpoint = envUrl.replace(/\/$/, '');
217
236
 
218
- config.dirs = { output: 'output', knowledge: 'knowledge' };
219
- config.api = { ...config.api, baseEndpoint };
237
+ merged.dirs = { output: 'output', knowledge: 'knowledge' };
238
+ merged.api = { ...merged.api, baseEndpoint };
220
239
  materializeKnowledge(this.site.dir);
240
+ return merged;
221
241
  }
222
242
 
223
243
  private async loadEnvConfig(): Promise<ApibotConfig> {
@@ -310,19 +330,7 @@ export class ApibotConfigParser {
310
330
  }
311
331
 
312
332
  private mergeWithDefaults(config: Partial<ApibotConfig>): ApibotConfig {
313
- return this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
314
- }
315
-
316
- private deepMerge(target: any, source: any): any {
317
- const result = { ...target };
318
- for (const key in source) {
319
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
320
- result[key] = this.deepMerge(result[key] || {}, source[key]);
321
- } else {
322
- result[key] = source[key];
323
- }
324
- }
325
- return result;
333
+ return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
326
334
  }
327
335
  }
328
336
 
@@ -230,7 +230,9 @@ export class Chief extends ChiefBase {
230
230
  - Use real enum values discovered in the data
231
231
  - Each test MUST use DIFFERENT data — never reuse the same field values across tests
232
232
  - For "create" tests: base payload on a real record but change field values to create new unique data
233
- - For "update" tests: pick a real existing ID and modify specific fields
233
+ - Treat records and IDs from sample_data as read-only. Never update, patch, delete, archive, or otherwise mutate them
234
+ - For update/delete tests: the same scenario must first create its own target, then mutate only that target
235
+ - For negative or unsupported-method tests that could mutate data if accepted: create a scenario-owned target first; if that setup is impossible, do not send the destructive request
234
236
  - For tests needing parent references: use real _id field values from sample_data
235
237
  `);
236
238
  }
@@ -243,6 +243,10 @@ export class Curler {
243
243
  - Record important findings as you go
244
244
  - Be precise about what you expect vs what you observe
245
245
  - If a test requires data from another endpoint, use schemaFor to look it up before guessing
246
+ - Treat existing records, sample data, and IDs supplied by the plan as read-only
247
+ - Before PUT, PATCH, DELETE, archive, or another destructive request, create the target inside the current scenario and mutate only that target
248
+ - This also applies when testing an unsupported method: the server may unexpectedly accept it, so never probe destructively against pre-existing data
249
+ - If a scenario-owned target cannot be created, use stop rather than risking existing data
246
250
  </rules>
247
251
  `;
248
252
  }
@@ -38,7 +38,7 @@ export function createApiCommands(name = 'api') {
38
38
  runOptions.baseEndpoint ||= runOptions.endpoint;
39
39
  try {
40
40
  const config = await parser.loadConfig(runOptions);
41
- console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), root: parser.getProjectRoot(), json: options.json }));
41
+ console.log(ConfigCommand.render(config, { configPath: parser.getConfigPath(), siteConfigPath: parser.getSiteConfigPath(), root: parser.getProjectRoot(), json: options.json }));
42
42
  }
43
43
  catch (error) {
44
44
  console.error(error instanceof Error ? error.message : 'Unknown error');
@@ -11,7 +11,8 @@ import path, { resolve } from 'node:path';
11
11
  import { pathToFileURL } from 'node:url';
12
12
  import { parseEnv } from 'node:util';
13
13
  import { ConfigMissingError, EXPLORBOT_CONFIG_PATHS, createModel, envConfigRequested, materializeKnowledge, missingConfigMessage, resolveConfigModels, resolveLangfuse, resolveModel, resolveOutputRoot, setOutputDir, } from "../../../src/config.js";
14
- import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
14
+ import { findGlobalConfig, globalEnvPath, isGlobalConfigPath, loadSiteConfig, registerSite, resolveSiteTarget } from "../../../src/global-config.js";
15
+ import { deepMerge } from "../../../src/utils/merge.js";
15
16
  function isAbsoluteEndpoint(value) {
16
17
  return !!value && (value.startsWith('http://') || value.startsWith('https://'));
17
18
  }
@@ -30,6 +31,7 @@ export class ApibotConfigParser {
30
31
  config = null;
31
32
  configPath = null;
32
33
  site = null;
34
+ siteConfigPath = null;
33
35
  constructor() { }
34
36
  static getInstance() {
35
37
  if (!ApibotConfigParser.instance) {
@@ -76,19 +78,21 @@ export class ApibotConfigParser {
76
78
  dirs: loadedConfig.dirs,
77
79
  };
78
80
  }
79
- this.config = this.mergeWithDefaults(loadedConfig);
80
- this.applyEnvSpec(this.config.api);
81
- this.applyEnvHeaders(this.config.api);
81
+ let config = this.mergeWithDefaults(loadedConfig);
82
+ this.applyEnvSpec(config.api);
83
+ this.applyEnvHeaders(config.api);
82
84
  if (options?.baseEndpoint)
83
- this.config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
84
- await resolveConfigModels(this.config.ai);
85
- resolveLangfuse(this.config.ai);
86
- this.configPath = resolvedPath;
85
+ config.api.baseEndpoint = options.baseEndpoint.replace(/\/$/, '');
86
+ await resolveConfigModels(config.ai);
87
+ resolveLangfuse(config.ai);
87
88
  this.site = null;
89
+ this.siteConfigPath = null;
88
90
  if (isGlobalConfigPath(resolvedPath)) {
89
- this.enterGlobalMode(this.config, options?.endpoint);
91
+ config = await this.enterGlobalMode(config, options?.endpoint);
90
92
  }
91
- this.validateConfig(this.config);
93
+ this.validateConfig(config);
94
+ this.config = config;
95
+ this.configPath = resolvedPath;
92
96
  setOutputDir(this.getOutputDir());
93
97
  return this.config;
94
98
  }
@@ -106,6 +110,9 @@ export class ApibotConfigParser {
106
110
  getConfigPath() {
107
111
  return this.configPath;
108
112
  }
113
+ getSiteConfigPath() {
114
+ return this.siteConfigPath;
115
+ }
109
116
  getOutputDir() {
110
117
  const config = this.getConfig();
111
118
  return path.join(this.getProjectRoot(), config.dirs?.output || 'output');
@@ -168,16 +175,27 @@ export class ApibotConfigParser {
168
175
  return;
169
176
  api.headers = { ...api.headers, ...parseHeaders(process.env.EXPLORBOT_API_HEADERS) };
170
177
  }
171
- enterGlobalMode(config, endpoint) {
178
+ async enterGlobalMode(config, endpoint) {
172
179
  const site = resolveSiteTarget(endpoint);
173
180
  this.site = registerSite(site.baseUrl);
181
+ const { path, config: siteConfig } = await loadSiteConfig(this.site.dir, site.baseUrl);
182
+ this.siteConfigPath = path;
183
+ const overrides = {};
184
+ if (siteConfig.ai)
185
+ overrides.ai = siteConfig.ai;
186
+ if (siteConfig.api)
187
+ overrides.api = siteConfig.api;
188
+ const merged = deepMerge(config, overrides);
189
+ await resolveConfigModels(merged.ai);
190
+ resolveLangfuse(merged.ai);
174
191
  let baseEndpoint = site.baseUrl;
175
192
  const envUrl = process.env.EXPLORBOT_URL;
176
193
  if (envUrl && URL.parse(envUrl)?.origin === site.baseUrl)
177
194
  baseEndpoint = envUrl.replace(/\/$/, '');
178
- config.dirs = { output: 'output', knowledge: 'knowledge' };
179
- config.api = { ...config.api, baseEndpoint };
195
+ merged.dirs = { output: 'output', knowledge: 'knowledge' };
196
+ merged.api = { ...merged.api, baseEndpoint };
180
197
  materializeKnowledge(this.site.dir);
198
+ return merged;
181
199
  }
182
200
  async loadEnvConfig() {
183
201
  const provider = process.env.EXPLORBOT_AI_PROVIDER;
@@ -261,18 +279,6 @@ export class ApibotConfigParser {
261
279
  }
262
280
  }
263
281
  mergeWithDefaults(config) {
264
- return this.deepMerge({ dirs: { output: 'output' }, api: {} }, config);
265
- }
266
- deepMerge(target, source) {
267
- const result = { ...target };
268
- for (const key in source) {
269
- if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) && source[key].constructor === Object) {
270
- result[key] = this.deepMerge(result[key] || {}, source[key]);
271
- }
272
- else {
273
- result[key] = source[key];
274
- }
275
- }
276
- return result;
282
+ return deepMerge({ dirs: { output: 'output' }, api: {} }, config);
277
283
  }
278
284
  }
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "explorbot",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "CLI app built with React Ink, CodeceptJS, and Playwright",
5
5
  "license": "Elastic-2.0",
6
6
  "type": "module",
@@ -2,6 +2,8 @@
2
2
  - Steps should specify exact HTTP methods, paths, and key payload details
3
3
  - Expected outcomes should be specific and verifiable (status codes, response fields, error messages)
4
4
  - For CRUD operations, each test should handle its own setup and teardown
5
+ - Treat existing records and IDs discovered from the API, knowledge, or sample data as read-only
6
+ - A scenario that updates, patches, deletes, archives, or otherwise mutates a record must create that target inside the same scenario first; omit the scenario if safe setup is impossible
5
7
  - Expect standard REST conventions: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found, 422 Unprocessable Entity
6
8
  - NEVER propose scenarios that test the same thing. "Create a basic suite" and "Successful creation of a simple suite" are DUPLICATES. Each scenario must test a DISTINCT behavior or aspect.
7
9
  - Before finalizing, review all scenarios and remove any that overlap in what they actually verify.
@@ -2,5 +2,6 @@
2
2
  When a section is a list that continues beyond what is shown, add one line under its `> Container:` line:
3
3
  `> Pagination: controls` — it has page numbers (1, 2, 3), prev/next arrows, or a "load more" button.
4
4
  `> Pagination: infinite` — it has none of those and loads more as it is scrolled.
5
+ Omit the line when the items already shown are the whole collection.
5
6
  Sorting, filtering and switching tabs are not pagination — omit the line then.
6
7
  </pagination>
@@ -552,10 +552,11 @@ export const attachStepLogger = (target, assertionsTarget) => {
552
552
  }
553
553
  tag('step').log(step);
554
554
  };
555
- codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, listener);
555
+ const onPassed = (step) => listener(step);
556
+ codeceptjs.event.dispatcher.on(codeceptjs.event.step.passed, onPassed);
556
557
  codeceptjs.event.dispatcher.on(codeceptjs.event.step.failed, listener);
557
558
  return () => {
558
- codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, listener);
559
+ codeceptjs.event.dispatcher.off(codeceptjs.event.step.passed, onPassed);
559
560
  codeceptjs.event.dispatcher.off(codeceptjs.event.step.failed, listener);
560
561
  };
561
562
  };
@@ -196,13 +196,19 @@ export function createAskApiTool(fisherman, task) {
196
196
  return {
197
197
  askApi: tool({
198
198
  description: dedent `
199
- Ask what data already exists, changing nothing.
200
- Ask a question about existing records: which ones are there, what they are called, whether a particular one exists.
201
- Use it before precondition() to see whether suitable data is already available, and whenever a step needs the exact name or id of a record that is already there.
199
+ Read the app's data over the API, changing nothing.
200
+ Answers questions about records: which ones are there, what they are called, whether a particular one exists.
201
+
202
+ Use it to:
203
+ - check whether suitable data already exists, before precondition() creates any
204
+ - get the exact name or id of a record a step must act on
205
+ - find out whether an action was stored, when the app reported success but the page does not show the result
206
+ - find out whether data exists at all, when a list or dropdown is empty
207
+
202
208
  It never creates, edits or deletes anything — precondition() does that.
203
209
  `,
204
210
  inputSchema: z.object({
205
- question: z.string().describe('What to find out about data that already exists'),
211
+ question: z.string().describe('What to find out about the data'),
206
212
  }),
207
213
  execute: async ({ question }) => {
208
214
  tag('info').log(`Ask API: ${question}`);
@@ -630,7 +630,7 @@ class Navigator {
630
630
  const cachedVerification = actionResult.getVerification(message);
631
631
  if (cachedVerification !== null) {
632
632
  tag('operation').log(`Reusing cached verification: ${cachedVerification ? 'PASS' : 'FAIL'}`);
633
- return { verified: cachedVerification, successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
633
+ return { verified: cachedVerification, inexpressible: false, results: [], successfulCodes: [], assertionSteps: [], totalAttempted: 0 };
634
634
  }
635
635
  const knowledge = this.knowledgeTracker.renderRelevantContext(actionResult);
636
636
  let experience = '';
@@ -741,9 +741,6 @@ class Navigator {
741
741
  observability: {
742
742
  agent: 'navigator',
743
743
  },
744
- catch: async (error) => {
745
- debugLog(error);
746
- },
747
744
  });
748
745
  }
749
746
  finally {
@@ -126,14 +126,6 @@ export class Pilot {
126
126
  ${sessionLog || 'No actions recorded'}
127
127
  </session_log>
128
128
 
129
- Decide and commit. "continue" extends the loop and burns iterations — choose it only when
130
- evidence is genuinely insufficient to call pass/fail, not as a safety hedge.
131
- - "pass" if final state proves the SCENARIO GOAL is accomplished. Set requestVerification.
132
- - "fail" if scenario was attempted but goal not achieved.
133
- - "skipped" if scenario is irrelevant/inapplicable, OR systematic infrastructure failures.
134
- - "continue" only when a concrete missing piece of evidence (a verify/see) would change your verdict.
135
- - Mixed evidence + final state shows success → pass. Mixed + final state unclear → continue with guidance.
136
-
137
129
  When deciding "pass", you MUST also set requestVerification to a one-sentence natural-language
138
130
  claim about the current page (e.g., "New item Foo is visible in the items list"). NOT
139
131
  code — do not write I.*, expect(), .then(), or any JavaScript. Choose the strongest single
@@ -352,7 +344,7 @@ export class Pilot {
352
344
  buildVerdictSystemPrompt(task) {
353
345
  return dedent `
354
346
  You are Pilot — final decision maker for test pass/fail. Review the evidence and commit to a
355
- verdict; "continue" only when evidence is genuinely insufficient.
347
+ verdict.
356
348
 
357
349
  ${capabilityGroundingRule}
358
350
 
@@ -366,10 +358,11 @@ export class Pilot {
366
358
  DOM assertion can't be made.
367
359
  Do not pass when Tester achieved only a related navigation/filter/tab/status outcome instead of the
368
360
  requested action, workflow, or entity detail goal.
369
- - "fail": scenario was attempted but the goal was not achieved.
361
+ - "fail": goal not achieved and no further step toward it is available on the current page.
370
362
  - "skipped": scenario is irrelevant to the app, OR systematic infrastructure failures (LLM errors,
371
363
  crashes) prevented testing. NOT for "test failed to interact" — that's "fail" or "continue".
372
- - "continue": tester hasn't completed the goal; provide concrete guidance (which tool, what to check).
364
+ - "continue": goal incomplete but the control for the NEXT step is present on the current page, or a
365
+ concrete missing check would change your verdict. Guidance must name that step.
373
366
  If a verify() asserted a state that was ALREADY TRUE before the test, it proves nothing — reject.
374
367
 
375
368
  reason field: one short sentence, maximum 120 characters. Do NOT restate the decision
@@ -929,14 +922,14 @@ export class Pilot {
929
922
  const lines = [];
930
923
  for (const [assertion, passed] of Object.entries(currentState.verifications ?? {})) {
931
924
  if (passed)
932
- lines.push(`state verification (passed): ${assertion}`);
925
+ lines.push(`verify: ${assertion}`);
933
926
  }
934
927
  for (const exec of testerConversation.getToolExecutions()) {
935
928
  if (!EVIDENCE_TOOLS.includes(exec.toolName) || !exec.wasSuccessful)
936
929
  continue;
937
930
  const description = exec.input?.assertion || exec.input?.request || truncateJson(exec.input);
938
- const result = exec.output?.message || exec.output?.analysis || exec.output?.result;
939
- lines.push(`CHECK ${exec.toolName} (executed successfully): ${description}${result ? ` -> ${result}` : ''}`);
931
+ const analysis = exec.output?.analysis;
932
+ lines.push(`${exec.toolName}: ${description}${analysis ? ` -> ${analysis}` : ''}`);
940
933
  }
941
934
  return [...new Set(lines)].join('\n');
942
935
  }
@@ -1030,7 +1023,9 @@ export class Pilot {
1030
1023
  ${interactive ? '- Use askUser() only as last resort.' : ''}
1031
1024
 
1032
1025
  Diagnostic patterns (use <state>, executed/element/skipped fields, ariaDiff):
1033
- - Click failed + button in "disabled buttons" → required field missing. Instruct fill first.
1026
+ - Scenario's target control in "disabled buttons" → a precondition is unmet; identify which before acting.
1027
+ Other disabled controls often name the unsatisfied constraint; "active form" marks [required] fields.
1028
+ Aim Tester at the constraint the page names, not the one the scenario assumed — note the difference in PROGRESS.
1034
1029
  - "overlay: none" but Tester targets an overlay → overlay closed; re-trigger.
1035
1030
  - "region:" in <state> → a large area appeared in place without navigation (subview, wizard step, panel). Direct Tester to act inside it; the rest of the page is still usable.
1036
1031
  - Action SUCCESS but ariaDiff empty → may have worked without visible DOM change; check result message.
@@ -1052,14 +1047,20 @@ export class Pilot {
1052
1047
  Tester tools: click, pressKey, form, see, verify, interact, context, research, xpathCheck,
1053
1048
  visualClick, back, getVisitedStates, reset, stop, finish, record.
1054
1049
  Use tool names exactly as listed. Do not invent combined names or aliases.
1050
+ Reloading is not a tool: to re-read a page from the server, instruct Tester to run I.reloadPage() through form.
1055
1051
 
1056
1052
  ${capabilityGroundingRule}
1057
1053
 
1058
1054
  YOUR Pilot-only tools, both over the API:
1059
1055
 
1060
- askApi(question) — ask what data already exists. It changes nothing. Use it to check whether
1061
- suitable data is already there before creating any, and to get the exact name or id of an existing
1062
- record a step must act on.
1056
+ askApi(question) — read the app's data over the API. It changes nothing. Use when:
1057
+
1058
+ - Before precondition() check whether suitable data already exists.
1059
+ - A step needs the exact name or id of an existing record.
1060
+ - The app reported success but the page does not show the result — ask whether it was stored.
1061
+ - A list or dropdown is empty — ask whether the data exists at all.
1062
+
1063
+ The page is not the only witness. A record missing from the page may still exist.
1063
1064
 
1064
1065
  precondition(description) — create FRESH disposable test data. Never request users. Use when:
1065
1066
 
@@ -1,4 +1,4 @@
1
- import type { Plan } from '../../test-plan.js';
1
+ import { type Plan, type Test } from '../../test-plan.js';
2
2
  import type { Constructor } from '../researcher/mixin.js';
3
3
  export declare function WithSessionDedup<T extends Constructor>(Base: T): {
4
4
  new (...args: any[]): {
@@ -9,4 +9,5 @@ export declare function WithSessionDedup<T extends Constructor>(Base: T): {
9
9
  getPreviousSessionScenariosExcluding(plan: Plan): Set<string>;
10
10
  };
11
11
  } & T;
12
+ export declare function formatSessionTest(plan: Plan, test: Test): string;
12
13
  export declare function clearSessionDedup(): void;
@@ -1,3 +1,4 @@
1
+ import { TestResult } from "../../test-plan.js";
1
2
  const previousPlans = [];
2
3
  export function WithSessionDedup(Base) {
3
4
  return class extends Base {
@@ -12,7 +13,7 @@ export function WithSessionDedup(Base) {
12
13
  if (plan === this.currentPlan)
13
14
  continue;
14
15
  for (const test of plan.tests) {
15
- lines.push(`${plan.url || '/'} | ${test.style || 'default'} | ${test.scenario}`);
16
+ lines.push(formatSessionTest(plan, test));
16
17
  }
17
18
  }
18
19
  return lines.join('\n');
@@ -25,6 +26,22 @@ export function WithSessionDedup(Base) {
25
26
  }
26
27
  };
27
28
  }
29
+ export function formatSessionTest(plan, test) {
30
+ const lastNote = Object.values(test.notes)
31
+ .filter((note) => note.message)
32
+ .pop();
33
+ let outcome = test.result;
34
+ if (!outcome)
35
+ outcome = 'pending';
36
+ if (!test.result && lastNote)
37
+ outcome = 'unfinished';
38
+ const line = `${plan.url || '/'} | ${test.style || 'default'} | ${outcome} | ${test.scenario}`;
39
+ if (!lastNote)
40
+ return line;
41
+ if (outcome !== TestResult.FAILED && outcome !== 'unfinished')
42
+ return line;
43
+ return `${line} — ${lastNote.message.slice(0, 140)}`;
44
+ }
28
45
  export function clearSessionDedup() {
29
46
  previousPlans.length = 0;
30
47
  }
@@ -316,8 +316,10 @@ export class Planner extends PlannerBase {
316
316
  You can suggest scenarios that can be tested only through web interface.
317
317
  You can't test emails, database, SMS, or any external services.
318
318
  Suggest scenarios that can be potentially verified by UI.
319
- Focus on error or success messages as outcome.
320
- Focus on URL page change or data persistency after page reload.
319
+ Prefer outcomes grounded in observed interface behavior.
320
+ Every expected outcome must be verifiable through the web interface.
321
+ If a page or subpage has not been observed, describe the expected visible result generically instead of inventing interface details.
322
+ Persistency after a reload counts only when the persisted state can be confirmed through the interface.
321
323
  If there are subpages (pages with same URL path) plan testing of those subpages as well
322
324
  Plan CRUD operations in order: create, read, update, delete.
323
325
  Do not invent specific route names, success messages, validation texts, badge counts, or welcome messages unless they are visible in research, visited pages, or prior observed flows.
@@ -328,7 +330,7 @@ export class Planner extends PlannerBase {
328
330
  If a scenario needs existing records, recipients, results, notifications, or other target data, propose it only when that data is visible, API preconditions can create it, or the scenario itself creates the record as its setup.
329
331
  If the page appears read-only, degraded, demo-limited, maintenance-like, or lacks write controls, prefer read-only scenarios such as opening panels, inspecting visible lists, filtering, searching, or verifying current state.
330
332
  Do not assume hidden data exists just because a control is present.
331
- For scenarios that act on existing items or search/filter by existing values, use only item names or values visible in research, visited pages, or prior observed flows.
333
+ Do not put record IDs or unique record names in test plans. Describe which record is needed and let Pilot choose it during execution; name a specific record only when research shows a small, complete list of available records.
332
334
  If the list is empty or no concrete item names are visible, do not invent "known" or "existing" items. Prefer empty-state, no-match search, clear-search, or read-only list behavior scenarios.
333
335
  Search, filter, sorting, tab, and list scenarios must start from a stable page where those controls are visible; avoid transient create/edit/new URLs unless the scenario tests that form.
334
336
  For option values and list items, use only visible or previously observed data; do not add create/update/delete setup unless the user explicitly requests that workflow.
@@ -540,7 +542,9 @@ export class Planner extends PlannerBase {
540
542
  const sessionTests = this.getSessionTestsSummary();
541
543
  if (sessionTests) {
542
544
  conversation.addUserText(dedent `
543
- Tests already planned in this session across all pages. DO NOT duplicate any of these:
545
+ Tests already planned in this session across all pages, with how each one ended. DO NOT duplicate any of these.
546
+ A failed test means the app or the harness could not do what it tried: do not re-propose the same behavior on another page unless you can name what makes it work this time.
547
+ A failed or unfinished test carries the last thing it observed after the dash — read it before deciding that the behavior is worth trying again.
544
548
 
545
549
  <session_tests>
546
550
  ${sessionTests}
@@ -32,6 +32,11 @@ function createHarmonyChannelFallbackTool() {
32
32
  execute: async () => ({ message: 'Noted. Continue with your next action.' }),
33
33
  });
34
34
  }
35
+ function withHarmonyChannelFallback(tools) {
36
+ if (tools?.commentary)
37
+ return tools;
38
+ return { ...tools, commentary: createHarmonyChannelFallbackTool() };
39
+ }
35
40
  let telemetryRegistered = false;
36
41
  let beforeExitFlushHooked = false;
37
42
  let activeOtelSdk = null;
@@ -367,8 +372,8 @@ export class Provider {
367
372
  setActivity(`🤖 Asking ${modelName} with dynamic tools`, 'ai');
368
373
  promptLog(`Using model: ${modelName}`);
369
374
  let toolsWithCommentary = tools;
370
- if (!tools?.commentary && options.toolChoice !== 'required')
371
- toolsWithCommentary = { ...tools, commentary: createHarmonyChannelFallbackTool() };
375
+ if (options.toolChoice !== 'required')
376
+ toolsWithCommentary = withHarmonyChannelFallback(tools);
372
377
  const toolNames = Object.keys(toolsWithCommentary || {});
373
378
  tag('debug').log(`Tools enabled: [${toolNames.join(', ')}]`);
374
379
  promptLog('Available tools:', toolNames);
@@ -378,9 +383,10 @@ export class Provider {
378
383
  const stopConditions = [isStepCount(maxRoundtrips)];
379
384
  if (extraStop)
380
385
  stopConditions.push(extraStop);
381
- const config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
386
+ let config = this.buildGenerateConfig({ tools: toolsWithCommentary, maxOutputTokens: 16384, toolChoice: 'auto', experimental_repairToolCall: repairToolCall }, { stopWhen: stopConditions, model }, options);
382
387
  let attemptMessages = messages;
383
388
  let invalidRequestFeedbackAdded = false;
389
+ let requiredToolChoiceRelaxed = false;
384
390
  const executedStepMessages = [];
385
391
  try {
386
392
  let response = await this.withModelRequestSlot(() => withRetry(async () => {
@@ -399,6 +405,11 @@ export class Provider {
399
405
  invalidRequestFeedbackAdded = amended !== attemptMessages;
400
406
  attemptMessages = amended;
401
407
  }
408
+ if (!requiredToolChoiceRelaxed && isRequiredToolChoiceError(error)) {
409
+ requiredToolChoiceRelaxed = true;
410
+ config = { ...config, tools: withHarmonyChannelFallback(tools), toolChoice: 'auto' };
411
+ tag('warning').log('Provider rejected required tool choice — retrying with automatic tool choice and channel fallback');
412
+ }
402
413
  throw error;
403
414
  }));
404
415
  this.recordUsage(options.agentName || 'unknown', modelName, result.usage);
@@ -422,7 +433,7 @@ export class Provider {
422
433
  }
423
434
  catch (error) {
424
435
  clearActivity();
425
- if (error?.message?.includes('Tool choice is required')) {
436
+ if (isRequiredToolChoiceError(error)) {
426
437
  return { text: '', toolCalls: [], toolResults: [], responseMessages: executedStepMessages, usage: null };
427
438
  }
428
439
  if (error?.name === 'AbortError')
@@ -659,6 +670,9 @@ function withInvalidRequestFeedback(messages, error) {
659
670
  },
660
671
  ];
661
672
  }
673
+ function isRequiredToolChoiceError(error) {
674
+ return error instanceof Error && error.message.includes('Tool choice is required');
675
+ }
662
676
  function repairChannelMarker({ toolCall, tools }) {
663
677
  const markerIndex = toolCall.toolName.indexOf('<|channel|>');
664
678
  if (markerIndex <= 0)
@@ -177,6 +177,7 @@ export const capabilityGroundingRule = dedent `
177
177
  When a scenario depends on a named action, menu item, status, option, workflow, or feature,
178
178
  that capability must be visible or explicitly confirmed in the current research/page context
179
179
  for the same target entity type.
180
+ Ground on the scenario's outcome, not a planned step's control label — a missing label is not a missing capability.
180
181
 
181
182
  Do not transfer capabilities between similar entities, rows, lists, detail pages, or menus.
182
183
  Do not replace a requested action with a synonym or related action unless the UI explicitly