playwright-intelligence-engine 2.0.1 → 2.0.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 (48) hide show
  1. package/README.md +229 -229
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/core/version.d.ts +3 -3
  4. package/dist/index.js +81 -24
  5. package/package.json +76 -76
  6. package/src/authoring/author.ts +81 -81
  7. package/src/authoring/generation-contract.ts +283 -283
  8. package/src/authoring/hybrid-author.ts +115 -115
  9. package/src/authoring/index.ts +7 -7
  10. package/src/authoring/llm-author.ts +282 -282
  11. package/src/authoring/naming/name-plan.ts +28 -28
  12. package/src/authoring/naming/name-planner.ts +339 -339
  13. package/src/authoring/naming/naming-linter.ts +292 -292
  14. package/src/cli/index.ts +1238 -1172
  15. package/src/collectors/git-diff-collector.ts +190 -190
  16. package/src/core/version.ts +25 -25
  17. package/src/diagnostics/diagnostic-report.ts +204 -204
  18. package/src/diagnostics/failure-classifier.ts +701 -701
  19. package/src/evaluation/repository-runner.ts +197 -197
  20. package/src/gate/quality.ts +101 -101
  21. package/src/generator/architecture-generator.ts +582 -582
  22. package/src/generator/assertion-quality-gate.ts +88 -88
  23. package/src/generator/candidate-writer.ts +288 -288
  24. package/src/generator/test-generator.ts +52 -52
  25. package/src/index.ts +107 -107
  26. package/src/intelligence/evidence-bundle.ts +300 -300
  27. package/src/intelligence/generation-agent.ts +561 -561
  28. package/src/intelligence/generation-verifier.ts +1115 -1115
  29. package/src/intelligence/locator-disambiguation-engine.ts +331 -331
  30. package/src/intelligence/project-intelligence.ts +140 -140
  31. package/src/intelligence/repository-profile.ts +796 -796
  32. package/src/intelligence/trace-proposal-workflow.ts +318 -318
  33. package/src/intelligence/workflow-planner.ts +966 -966
  34. package/src/mcp/server.ts +970 -970
  35. package/src/perception/app-model.ts +32 -32
  36. package/src/perception/crawler.ts +263 -263
  37. package/src/perception/index.ts +3 -3
  38. package/src/repair/assertion-hasher.ts +386 -386
  39. package/src/repair/diff-allowlist.ts +612 -612
  40. package/src/repair/intent-validator.ts +761 -761
  41. package/src/repair/repair-planner.ts +467 -467
  42. package/src/repair/statement-patcher.ts +544 -544
  43. package/src/reproduce/interactive-workflow-runner.ts +828 -828
  44. package/src/sandbox/execution-runner.ts +408 -408
  45. package/src/selector/abstention-policy.ts +175 -175
  46. package/src/selector/candidate-extractor.ts +343 -343
  47. package/src/selector/repair-candidate-ranker.ts +92 -92
  48. package/src/selector/selector-scorer.ts +344 -344
@@ -1,561 +1,561 @@
1
- import { z } from 'zod';
2
- import {
3
- ArchitecturePlan,
4
- ArchitectureWriteResult,
5
- planArchitecture,
6
- toSlug,
7
- writeArchitecturePlan,
8
- } from '../generator/architecture-generator.js';
9
- import { AssertionQualityGate } from '../generator/assertion-quality-gate.js';
10
- import { CandidateWriter, CandidateWriteResult } from '../generator/candidate-writer.js';
11
- import { GeneratedStep } from '../generator/test-generator.js';
12
- import { buildRepositoryProfile, RepositoryConventionProfile } from './repository-profile.js';
13
- import { buildTestIntelligencePlan, ExistingTestInput, RequirementInput, TestIntelligencePlan } from './test-intelligence.js';
14
- import { ExecutionRunner, ExecutionResult } from '../sandbox/execution-runner.js';
15
- import {
16
- Author,
17
- AuthorKind,
18
- CandidateChange,
19
- FullCodeGenerationProvider,
20
- HybridAuthor,
21
- LlmAuthor,
22
- NamingLinter,
23
- TemplateAuthor,
24
- } from '../authoring/index.js';
25
- import { QualityGate, QualityGateResult } from '../gate/index.js';
26
-
27
- const GeneratedStepSchema = z.object({
28
- action: z.enum(['click', 'fill', 'press', 'navigate', 'assert-visible', 'assert-text', 'assert-count', 'assert-value']),
29
- role: z.string().min(1).optional(),
30
- name: z.string().min(1).optional(),
31
- selector: z.string().min(1).optional(),
32
- value: z.string().optional(),
33
- placeholder: z.string().optional(),
34
- expectedText: z.string().optional(),
35
- expectedCount: z.number().optional(),
36
- expectedValue: z.string().optional(),
37
- frame: z.string().optional(),
38
- frameSelector: z.string().optional(),
39
- }).strict();
40
-
41
- const GeneratedScenarioSchema = z.object({
42
- id: z.string().min(1),
43
- title: z.string().min(1),
44
- requirementId: z.string().min(1),
45
- steps: z.array(GeneratedStepSchema).min(1).max(100),
46
- reasoningSummary: z.string().min(1),
47
- evidenceReferences: z.array(z.string()),
48
- }).strict();
49
-
50
- export const GenerationPlanSchema = z.object({
51
- planVersion: z.literal('1'),
52
- status: z.enum(['GENERATE', 'NEEDS_INPUT', 'ABSTAIN']),
53
- reasoningSummary: z.string().min(1),
54
- scenarios: z.array(GeneratedScenarioSchema).max(30),
55
- requiredClarifications: z.array(z.string()),
56
- evidenceReferences: z.array(z.string()),
57
- }).strict();
58
-
59
- export type GenerationPlan = z.infer<typeof GenerationPlanSchema>;
60
-
61
- export interface GenerationPlanInput {
62
- requirements: RequirementInput[];
63
- existingTests?: ExistingTestInput[] | undefined;
64
- mode: 'greenfield' | 'brownfield';
65
- repositoryProfile?: RepositoryConventionProfile | undefined;
66
- baseUrl?: string | undefined;
67
- suggestedSteps?: GeneratedStep[] | undefined;
68
- }
69
-
70
- export interface GenerationPlanProvider {
71
- propose(input: GenerationPlanInput, intelligence: TestIntelligencePlan): Promise<GenerationPlanParseResult>;
72
- }
73
-
74
- export interface GenerationPlanParseResult {
75
- isValid: boolean;
76
- plan?: GenerationPlan | undefined;
77
- errors: string[];
78
- }
79
-
80
- export interface GenerationAgentInput extends GenerationPlanInput {
81
- workspaceRoot?: string | undefined;
82
- provider?: GenerationPlanProvider | undefined;
83
- fullCodeProvider?: FullCodeGenerationProvider | undefined;
84
- authorImpl?: Author | undefined;
85
- write?: boolean | undefined;
86
- allowModify?: boolean | undefined;
87
- existingFiles?: string[] | undefined;
88
- includeCi?: boolean | undefined;
89
- verify?: boolean | undefined;
90
- author?: AuthorKind | undefined;
91
- maxSteps?: number | undefined;
92
- maxDurationMs?: number | undefined;
93
- contextPacket?: unknown;
94
- workflowPlan?: unknown;
95
- explorationBundle?: unknown;
96
- allowedEvidenceIds?: string[] | undefined;
97
- }
98
-
99
- export interface GeneratedScenarioResult {
100
- scenario: GenerationPlan['scenarios'][number];
101
- architecture: ArchitecturePlan;
102
- candidate?: CandidateChange | undefined;
103
- writeResult?: ArchitectureWriteResult | undefined;
104
- candidateWriteResult?: CandidateWriteResult | undefined;
105
- verification?: ExecutionResult | undefined;
106
- loopState?: 'SUCCESS' | 'AGENT_NO_PROGRESS' | 'AGENT_TIME_BUDGET' | 'MAX_STEPS_EXCEEDED' | undefined;
107
- stepsCount?: number | undefined;
108
- qualityGateResult?: QualityGateResult | undefined;
109
- }
110
-
111
- export interface GenerationAgentResult {
112
- status: 'MODEL_NOT_CONFIGURED' | 'ABSTAINED' | 'NEEDS_INPUT' | 'PLANNED' | 'WRITTEN' | 'VERIFIED' | 'VERIFICATION_FAILED' | 'AGENT_NO_PROGRESS';
113
- provider: 'llm' | 'deterministic-fallback';
114
- authorKind?: AuthorKind | undefined;
115
- intelligence: TestIntelligencePlan;
116
- plan?: GenerationPlan | undefined;
117
- scenarios: GeneratedScenarioResult[];
118
- errors: string[];
119
- }
120
-
121
- function parseJson(input: unknown): unknown {
122
- if (typeof input !== 'string') return input;
123
- const fenced = input.trim().match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1];
124
- const candidate = (fenced ?? input).trim();
125
- const start = candidate.indexOf('{');
126
- const end = candidate.lastIndexOf('}');
127
- return JSON.parse(start >= 0 && end > start ? candidate.slice(start, end + 1) : candidate);
128
- }
129
-
130
- export function parseGenerationPlan(input: unknown): GenerationPlanParseResult {
131
- try {
132
- const parsed = GenerationPlanSchema.safeParse(parseJson(input));
133
- if (!parsed.success) return { isValid: false, errors: parsed.error.issues.map((issue) => issue.message) };
134
- if (parsed.data.status === 'GENERATE' && parsed.data.scenarios.length === 0) {
135
- return { isValid: false, errors: ['GENERATE plans must contain at least one scenario'] };
136
- }
137
- if (parsed.data.status === 'NEEDS_INPUT' && parsed.data.requiredClarifications.length === 0) {
138
- return { isValid: false, errors: ['NEEDS_INPUT plans must include requiredClarifications'] };
139
- }
140
- return { isValid: true, plan: parsed.data, errors: [] };
141
- } catch (error) {
142
- return { isValid: false, errors: [`Generation response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`] };
143
- }
144
- }
145
-
146
- function generationSystemPrompt(): string {
147
- return 'Return exactly one JSON object conforming to the schema:\n' +
148
- '{\n' +
149
- ' "planVersion": "1",\n' +
150
- ' "status": "GENERATE",\n' +
151
- ' "reasoningSummary": "string",\n' +
152
- ' "scenarios": [\n' +
153
- ' {\n' +
154
- ' "id": "scenario-1",\n' +
155
- ' "title": "string",\n' +
156
- ' "requirementId": "string",\n' +
157
- ' "steps": [\n' +
158
- ' { "action": "navigate", "value": "/url" },\n' +
159
- ' { "action": "fill", "role": "textbox", "name": "Field", "value": "val" },\n' +
160
- ' { "action": "click", "role": "button", "name": "Button" },\n' +
161
- ' { "action": "assert-text", "role": "status", "name": "Label", "expectedText": "Expected" }\n' +
162
- ' ],\n' +
163
- ' "reasoningSummary": "string",\n' +
164
- ' "evidenceReferences": []\n' +
165
- ' }\n' +
166
- ' ],\n' +
167
- ' "requiredClarifications": [],\n' +
168
- ' "evidenceReferences": []\n' +
169
- '}\n' +
170
- 'Steps may only use click, fill, navigate, assert-visible, or assert-text. Every scenario MUST contain at least one step in its steps array. Do not return raw code, patches, or unsupported fields.';
171
- }
172
-
173
- export interface OpenAiCompatibleGenerationProviderOptions {
174
- endpoint: string;
175
- model: string;
176
- apiKey?: string | undefined;
177
- timeoutMs?: number | undefined;
178
- fetchImpl?: typeof fetch | undefined;
179
- }
180
-
181
- /** Provider-neutral boundary with an OpenAI-compatible transport implementation. */
182
- export class OpenAiCompatibleGenerationProvider implements GenerationPlanProvider {
183
- public constructor(private readonly options: OpenAiCompatibleGenerationProviderOptions) {}
184
-
185
- public async propose(input: GenerationPlanInput, intelligence: TestIntelligencePlan): Promise<GenerationPlanParseResult> {
186
- const controller = new AbortController();
187
- const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 60_000);
188
- try {
189
- const response = await (this.options.fetchImpl ?? fetch)(this.options.endpoint, {
190
- method: 'POST',
191
- signal: controller.signal,
192
- headers: {
193
- 'content-type': 'application/json',
194
- ...(this.options.apiKey ? { authorization: `Bearer ${this.options.apiKey}` } : {}),
195
- },
196
- body: JSON.stringify({
197
- model: this.options.model,
198
- temperature: 0,
199
- response_format: { type: 'json_object' },
200
- messages: [
201
- { role: 'system', content: generationSystemPrompt() },
202
- { role: 'user', content: JSON.stringify({ input, intelligence }) },
203
- ],
204
- }),
205
- });
206
- if (!response.ok) return { isValid: false, errors: [`Model endpoint returned HTTP ${response.status}`] };
207
- const payload = await response.json() as { choices?: Array<{ message?: { content?: string | Array<{ text?: string }> | null } }>; output_text?: string };
208
- const content = payload.output_text
209
- ?? (typeof payload.choices?.[0]?.message?.content === 'string'
210
- ? payload.choices[0].message.content
211
- : payload.choices?.[0]?.message?.content?.map((part) => part.text ?? '').join(''));
212
- if (!content) return { isValid: false, errors: ['Model endpoint returned no generation plan content'] };
213
- return parseGenerationPlan(content);
214
- } catch (error) {
215
- return { isValid: false, errors: [error instanceof Error && error.name === 'AbortError' ? 'Generation model request timed out' : `Generation model request failed: ${error instanceof Error ? error.message : String(error)}`] };
216
- } finally {
217
- clearTimeout(timeout);
218
- }
219
- }
220
- }
221
-
222
- function configuredGenerationProvider(): GenerationPlanProvider | undefined {
223
- const liveAllowed = !process.env.VITEST || process.env.PIE_LIVE_LLM_TESTS === '1';
224
- const apiKey = liveAllowed ? (process.env.PIE_LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? process.env.DEEPSEEK_API_KEY) : undefined;
225
- const endpoint = process.env.PIE_LLM_ENDPOINT ?? (process.env.OPENAI_API_KEY ? 'https://api.openai.com/v1/chat/completions' : (apiKey ? 'https://api.deepseek.com/chat/completions' : undefined));
226
- const model = process.env.PIE_LLM_MODEL ?? (process.env.OPENAI_API_KEY ? 'gpt-4o' : (apiKey ? 'deepseek-chat' : undefined));
227
- if (!endpoint || !model) return undefined;
228
- return new OpenAiCompatibleGenerationProvider({
229
- endpoint,
230
- model,
231
- ...(apiKey ? { apiKey } : {}),
232
- });
233
- }
234
-
235
- function validateGeneratedScenario(
236
- scenario: GenerationPlan['scenarios'][number],
237
- requirementIds: Set<string>,
238
- ): string[] {
239
- const errors: string[] = [];
240
- if (!requirementIds.has(scenario.requirementId)) errors.push(`Scenario '${scenario.id}' references unknown requirement '${scenario.requirementId}'`);
241
- for (const [index, step] of scenario.steps.entries()) {
242
- const hasSemanticTarget = step.action === 'fill'
243
- ? Boolean(step.name || (step.role && step.name))
244
- : Boolean(step.role && step.name);
245
- const hasExplicitTarget = Boolean(step.selector);
246
- if ((step.action === 'click' || step.action === 'fill') && !hasSemanticTarget && !hasExplicitTarget) {
247
- errors.push(`Scenario '${scenario.id}' step ${index + 1} requires role/name or an explicit selector`);
248
- }
249
- if (step.action === 'assert-visible' && !hasSemanticTarget && !hasExplicitTarget) {
250
- errors.push(`Scenario '${scenario.id}' visibility assertion requires role/name or an explicit selector`);
251
- }
252
- if (step.action === 'assert-text') {
253
- const text = step.expectedText?.trim() || step.value?.trim();
254
- if (!text) {
255
- errors.push(`Scenario '${scenario.id}' text assertion requires expectedText or value`);
256
- } else if (!step.expectedText) {
257
- step.expectedText = text;
258
- }
259
- }
260
- }
261
- return errors;
262
- }
263
-
264
- export async function runGenerationAgent(input: GenerationAgentInput): Promise<GenerationAgentResult> {
265
- const intelligence = buildTestIntelligencePlan(input.requirements, input.existingTests ?? []);
266
- const profile = input.repositoryProfile ?? (input.mode === 'brownfield' ? await buildRepositoryProfile(input.workspaceRoot ?? process.cwd()) : undefined);
267
- const provider = input.provider ?? configuredGenerationProvider();
268
- if (input.verify === true && input.write !== true) {
269
- return { status: 'ABSTAINED', provider: provider ? 'llm' : 'deterministic-fallback', intelligence, scenarios: [], errors: ['Generation verification requires --write so the generated spec exists in the target workspace'] };
270
- }
271
-
272
- const authorKind: AuthorKind = input.author ?? 'hybrid';
273
- const author: Author = input.authorImpl ?? (
274
- authorKind === 'template'
275
- ? new TemplateAuthor()
276
- : authorKind === 'llm'
277
- ? new LlmAuthor({ provider: input.fullCodeProvider })
278
- : new HybridAuthor()
279
- );
280
-
281
- // If Tier-L2 fullCodeProvider is supplied with author='llm' and no custom step provider,
282
- // allow direct full-code generation without forcing the 5-verb step planner first.
283
- const effectiveProvider = (authorKind === 'llm' && input.fullCodeProvider && !input.provider) ? undefined : provider;
284
-
285
- let generatedPlan: GenerationPlan;
286
- let providerMode: 'llm' | 'deterministic-fallback' = 'llm';
287
- if (effectiveProvider) {
288
- const response = await effectiveProvider.propose({
289
- requirements: input.requirements,
290
- existingTests: input.existingTests,
291
- mode: input.mode,
292
- repositoryProfile: profile,
293
- baseUrl: input.baseUrl,
294
- suggestedSteps: input.suggestedSteps,
295
- }, intelligence);
296
- if (!response.isValid || !response.plan) return { status: 'ABSTAINED', provider: 'llm', intelligence, scenarios: [], errors: response.errors };
297
- if (response.plan.status === 'ABSTAIN') return { status: 'ABSTAINED', provider: 'llm', intelligence, plan: response.plan, scenarios: [], errors: [response.plan.reasoningSummary] };
298
- if (response.plan.status === 'NEEDS_INPUT') return { status: 'NEEDS_INPUT', provider: 'llm', intelligence, plan: response.plan, scenarios: [], errors: response.plan.requiredClarifications };
299
- generatedPlan = response.plan;
300
- } else {
301
- providerMode = authorKind === 'llm' && input.fullCodeProvider ? 'llm' : 'deterministic-fallback';
302
- generatedPlan = {
303
- planVersion: '1',
304
- status: 'GENERATE',
305
- reasoningSummary: authorKind === 'llm' && input.fullCodeProvider
306
- ? 'Tier-L2 full-code provider configured; synthesizing scenarios directly via LlmAuthor.'
307
- : 'No LLM provider is configured; generated a conservative deterministic scaffold from the supplied requirements.',
308
- scenarios: input.requirements.map((requirement) => ({
309
- id: `scenario-${requirement.id}`,
310
- title: requirement.title,
311
- requirementId: requirement.id,
312
- steps: input.suggestedSteps && input.suggestedSteps.length > 0
313
- ? input.suggestedSteps
314
- : [{ action: 'navigate' as const }, { action: 'assert-visible' as const, role: 'heading', name: requirement.title }],
315
- reasoningSummary: 'Scenario derived directly from the supplied requirement; business assertions require review.',
316
- evidenceReferences: [`requirement:${requirement.id}`],
317
- })),
318
- requiredClarifications: [],
319
- evidenceReferences: input.requirements.map((requirement) => `requirement:${requirement.id}`),
320
- };
321
- }
322
-
323
- const requirementIds = new Set(input.requirements.map((requirement) => requirement.id));
324
- const scenarioIds = new Set<string>();
325
- const scenarioValidationErrors = generatedPlan.scenarios.flatMap((scenario) => {
326
- const errors = scenarioIds.has(scenario.id) ? [`Duplicate generated scenario id '${scenario.id}'`] : [];
327
- scenarioIds.add(scenario.id);
328
- return [...errors, ...validateGeneratedScenario(scenario, requirementIds)];
329
- });
330
- if (scenarioValidationErrors.length > 0) {
331
- return { status: 'ABSTAINED', provider: providerMode, intelligence, plan: generatedPlan, scenarios: [], errors: scenarioValidationErrors };
332
- }
333
-
334
- const scenarios: GeneratedScenarioResult[] = [];
335
- for (const scenario of generatedPlan.scenarios) {
336
- const maxSteps = input.maxSteps ?? 3;
337
- const maxDurationMs = input.maxDurationMs ?? 120_000;
338
- const startTime = Date.now();
339
- const seenSignatures = new Set<string>();
340
-
341
- let candidate: CandidateChange | undefined;
342
- let gateResult: QualityGateResult | undefined;
343
- let loopState: 'SUCCESS' | 'AGENT_NO_PROGRESS' | 'AGENT_TIME_BUDGET' | 'MAX_STEPS_EXCEEDED' = 'SUCCESS';
344
- let stepCount = 0;
345
-
346
- for (let stepIndex = 0; stepIndex < maxSteps; stepIndex++) {
347
- stepCount++;
348
- if (Date.now() - startTime >= maxDurationMs) {
349
- loopState = 'AGENT_TIME_BUDGET';
350
- break;
351
- }
352
-
353
- candidate = await author.propose({
354
- featureName: scenario.title,
355
- mode: input.mode,
356
- baseUrl: input.baseUrl,
357
- requirements: input.requirements.find((r) => r.id === scenario.requirementId)?.description
358
- ?? input.requirements.find((r) => r.id === scenario.requirementId)?.title,
359
- steps: scenario.steps as GeneratedStep[],
360
- profile,
361
- existingFiles: input.existingFiles,
362
- exemplars: profile?.exemplars,
363
- contextPacket: input.contextPacket,
364
- workflowPlan: input.workflowPlan,
365
- explorationBundle: input.explorationBundle,
366
- scenarios: generatedPlan.scenarios,
367
- allowedEvidenceIds: input.allowedEvidenceIds,
368
- fullCodeProvider: input.fullCodeProvider,
369
- });
370
-
371
- if (candidate.status === 'NEEDS_INPUT') {
372
- return {
373
- status: 'NEEDS_INPUT',
374
- provider: providerMode,
375
- authorKind,
376
- intelligence,
377
- plan: generatedPlan,
378
- scenarios,
379
- errors: candidate.requiredClarifications?.length ? candidate.requiredClarifications : [candidate.rationale],
380
- };
381
- }
382
-
383
- if (candidate.status === 'ABSTAIN' && candidate.fullCodeAuthoring) {
384
- gateResult = {
385
- passed: false,
386
- duplicateSelectors: [],
387
- locatorPolicyViolations: [],
388
- assertionStrengthPassed: false,
389
- changedLines: 0,
390
- violations: candidate.validationErrors?.length ? candidate.validationErrors : [candidate.rationale],
391
- };
392
- } else {
393
- gateResult = QualityGate.evaluate({
394
- symbols: candidate.symbols,
395
- files: candidate.files,
396
- locatorPolicy: profile?.identifierConventions?.recommendedLocatorPolicy,
397
- });
398
-
399
- if (candidate.fullCodeAuthoring) {
400
- for (const spec of candidate.files.filter((f) => /\.(?:spec|test)\.(?:ts|tsx|js|jsx|mjs)$/i.test(f.relativePath))) {
401
- const aqg = AssertionQualityGate.evaluate(spec.content, scenario.title);
402
- if (!aqg.passed) {
403
- gateResult.passed = false;
404
- gateResult.assertionStrengthPassed = false;
405
- gateResult.violations.push(...aqg.violations);
406
- }
407
- }
408
- }
409
- }
410
-
411
- const signature = `${candidate.symbols.pageClassName}:${candidate.symbols.symbols.map((s) => `${s.property}:${s.method}`).join(',')}:${gateResult.violations.join(';')}`;
412
-
413
- if (seenSignatures.has(signature)) {
414
- loopState = 'AGENT_NO_PROGRESS';
415
- break;
416
- }
417
- seenSignatures.add(signature);
418
-
419
- if (gateResult.passed) {
420
- loopState = 'SUCCESS';
421
- break;
422
- }
423
-
424
- if (stepIndex === maxSteps - 1) {
425
- loopState = 'MAX_STEPS_EXCEEDED';
426
- }
427
- }
428
-
429
- const preserveCandidateFiles = Boolean(
430
- candidate &&
431
- candidate.files.length > 0 &&
432
- (candidate.fullCodeAuthoring === true || candidate.authorKind === 'llm'),
433
- );
434
-
435
- const architecture: ArchitecturePlan = preserveCandidateFiles && candidate
436
- ? {
437
- mode: input.mode,
438
- status: candidate.status === 'ABSTAIN' ? 'ABSTAIN' : 'READY',
439
- featureName: scenario.title,
440
- slug: toSlug(scenario.title),
441
- conventions: {
442
- testDirectory: profile?.paths?.testDirectory ?? 'tests',
443
- pageObjectDirectory: profile?.paths?.pageObjectDirectory ?? 'tests/pages',
444
- fixtureDirectory: profile?.paths?.fixtureDirectory ?? 'tests/fixtures',
445
- preferredLocator: profile?.locatorConventions.preferred ?? 'getByRole',
446
- usesCustomFixtures: profile?.structure.usesCustomFixtures ?? false,
447
- usesStorageState: profile?.structure.usesStorageState ?? false,
448
- },
449
- assumptions: candidate.assumptions ?? [],
450
- warnings: candidate.validationErrors ?? [],
451
- files: candidate.files.map((file) => ({
452
- relativePath: file.relativePath,
453
- content: file.content,
454
- purpose: file.purpose ?? file.rationale ?? 'Author-generated file',
455
- action: file.action ?? 'CREATE',
456
- })),
457
- namePlan: candidate.symbols,
458
- namingLintReport: NamingLinter.lint(candidate.symbols),
459
- }
460
- : planArchitecture({
461
- featureName: scenario.title,
462
- mode: input.mode,
463
- baseUrl: input.baseUrl,
464
- requirements: input.requirements.find((requirement) => requirement.id === scenario.requirementId)?.description
465
- ?? input.requirements.find((requirement) => requirement.id === scenario.requirementId)?.title,
466
- steps: scenario.steps as GeneratedStep[],
467
- profile,
468
- existingFiles: input.existingFiles,
469
- includeCi: input.includeCi,
470
- namePlan: candidate?.symbols,
471
- });
472
-
473
- let writeResult: ArchitectureWriteResult | undefined;
474
- let candidateWriteResult: CandidateWriteResult | undefined;
475
- if (input.write === true && loopState === 'SUCCESS') {
476
- const workspaceRoot = input.workspaceRoot ?? process.cwd();
477
- if (preserveCandidateFiles && candidate) {
478
- candidateWriteResult = await CandidateWriter.apply(workspaceRoot, candidate, {
479
- allowModify: input.allowModify ?? true,
480
- validateLocalImports: false,
481
- });
482
- if (!candidateWriteResult.success) {
483
- return {
484
- status: 'ABSTAINED',
485
- provider: providerMode,
486
- authorKind,
487
- intelligence,
488
- plan: generatedPlan,
489
- scenarios,
490
- errors: candidateWriteResult.errors,
491
- };
492
- }
493
- writeResult = {
494
- written: candidateWriteResult.written,
495
- skipped: candidateWriteResult.skipped,
496
- modified: candidateWriteResult.modified,
497
- };
498
- } else {
499
- writeResult = await writeArchitecturePlan(workspaceRoot, architecture);
500
- }
501
- }
502
-
503
- let verification: ExecutionResult | undefined;
504
- if (input.verify === true && loopState === 'SUCCESS') {
505
- const spec = architecture.files.find((file) => /\.(?:spec|test)\.(?:ts|tsx|js|jsx|mjs)$/i.test(file.relativePath));
506
- if (!spec) throw new Error(`Generated scenario '${scenario.id}' did not produce a spec file`);
507
- verification = await ExecutionRunner.run('npx', {
508
- cwd: input.workspaceRoot ?? process.cwd(),
509
- args: ['playwright', 'test', spec.relativePath, '--reporter=line'],
510
- timeoutMs: 120_000,
511
- });
512
- }
513
-
514
- scenarios.push({
515
- scenario,
516
- architecture,
517
- ...(candidate ? { candidate } : {}),
518
- ...(writeResult ? { writeResult } : {}),
519
- ...(candidateWriteResult ? { candidateWriteResult } : {}),
520
- ...(verification ? { verification } : {}),
521
- loopState,
522
- stepsCount: stepCount,
523
- ...(gateResult ? { qualityGateResult: gateResult } : {}),
524
- });
525
- }
526
-
527
- const anyNoProgress = scenarios.some((s) => s.loopState === 'AGENT_NO_PROGRESS');
528
- const anyGateFailed = scenarios.some((s) => s.qualityGateResult && !s.qualityGateResult.passed);
529
- const anyVerificationFailed = scenarios.some((s) => s.verification && s.verification.exitCode !== 0);
530
-
531
- const errors: string[] = [];
532
- if (anyNoProgress) {
533
- errors.push('Generation agent detected no progress: author proposed duplicate invalid states.');
534
- }
535
- if (anyGateFailed) {
536
- errors.push(...scenarios.flatMap((s) => s.qualityGateResult?.violations ?? []));
537
- }
538
-
539
- let status: GenerationAgentResult['status'];
540
- if (anyNoProgress) {
541
- status = 'AGENT_NO_PROGRESS';
542
- } else if (anyGateFailed) {
543
- status = 'ABSTAINED';
544
- } else if (input.verify === true) {
545
- status = anyVerificationFailed ? 'VERIFICATION_FAILED' : 'VERIFIED';
546
- } else if (input.write === true) {
547
- status = 'WRITTEN';
548
- } else {
549
- status = 'PLANNED';
550
- }
551
-
552
- return {
553
- status,
554
- provider: providerMode,
555
- authorKind,
556
- intelligence,
557
- plan: generatedPlan,
558
- scenarios,
559
- errors,
560
- };
561
- }
1
+ import { z } from 'zod';
2
+ import {
3
+ ArchitecturePlan,
4
+ ArchitectureWriteResult,
5
+ planArchitecture,
6
+ toSlug,
7
+ writeArchitecturePlan,
8
+ } from '../generator/architecture-generator.js';
9
+ import { AssertionQualityGate } from '../generator/assertion-quality-gate.js';
10
+ import { CandidateWriter, CandidateWriteResult } from '../generator/candidate-writer.js';
11
+ import { GeneratedStep } from '../generator/test-generator.js';
12
+ import { buildRepositoryProfile, RepositoryConventionProfile } from './repository-profile.js';
13
+ import { buildTestIntelligencePlan, ExistingTestInput, RequirementInput, TestIntelligencePlan } from './test-intelligence.js';
14
+ import { ExecutionRunner, ExecutionResult } from '../sandbox/execution-runner.js';
15
+ import {
16
+ Author,
17
+ AuthorKind,
18
+ CandidateChange,
19
+ FullCodeGenerationProvider,
20
+ HybridAuthor,
21
+ LlmAuthor,
22
+ NamingLinter,
23
+ TemplateAuthor,
24
+ } from '../authoring/index.js';
25
+ import { QualityGate, QualityGateResult } from '../gate/index.js';
26
+
27
+ const GeneratedStepSchema = z.object({
28
+ action: z.enum(['click', 'fill', 'press', 'navigate', 'assert-visible', 'assert-text', 'assert-count', 'assert-value']),
29
+ role: z.string().min(1).optional(),
30
+ name: z.string().min(1).optional(),
31
+ selector: z.string().min(1).optional(),
32
+ value: z.string().optional(),
33
+ placeholder: z.string().optional(),
34
+ expectedText: z.string().optional(),
35
+ expectedCount: z.number().optional(),
36
+ expectedValue: z.string().optional(),
37
+ frame: z.string().optional(),
38
+ frameSelector: z.string().optional(),
39
+ }).strict();
40
+
41
+ const GeneratedScenarioSchema = z.object({
42
+ id: z.string().min(1),
43
+ title: z.string().min(1),
44
+ requirementId: z.string().min(1),
45
+ steps: z.array(GeneratedStepSchema).min(1).max(100),
46
+ reasoningSummary: z.string().min(1),
47
+ evidenceReferences: z.array(z.string()),
48
+ }).strict();
49
+
50
+ export const GenerationPlanSchema = z.object({
51
+ planVersion: z.literal('1'),
52
+ status: z.enum(['GENERATE', 'NEEDS_INPUT', 'ABSTAIN']),
53
+ reasoningSummary: z.string().min(1),
54
+ scenarios: z.array(GeneratedScenarioSchema).max(30),
55
+ requiredClarifications: z.array(z.string()),
56
+ evidenceReferences: z.array(z.string()),
57
+ }).strict();
58
+
59
+ export type GenerationPlan = z.infer<typeof GenerationPlanSchema>;
60
+
61
+ export interface GenerationPlanInput {
62
+ requirements: RequirementInput[];
63
+ existingTests?: ExistingTestInput[] | undefined;
64
+ mode: 'greenfield' | 'brownfield';
65
+ repositoryProfile?: RepositoryConventionProfile | undefined;
66
+ baseUrl?: string | undefined;
67
+ suggestedSteps?: GeneratedStep[] | undefined;
68
+ }
69
+
70
+ export interface GenerationPlanProvider {
71
+ propose(input: GenerationPlanInput, intelligence: TestIntelligencePlan): Promise<GenerationPlanParseResult>;
72
+ }
73
+
74
+ export interface GenerationPlanParseResult {
75
+ isValid: boolean;
76
+ plan?: GenerationPlan | undefined;
77
+ errors: string[];
78
+ }
79
+
80
+ export interface GenerationAgentInput extends GenerationPlanInput {
81
+ workspaceRoot?: string | undefined;
82
+ provider?: GenerationPlanProvider | undefined;
83
+ fullCodeProvider?: FullCodeGenerationProvider | undefined;
84
+ authorImpl?: Author | undefined;
85
+ write?: boolean | undefined;
86
+ allowModify?: boolean | undefined;
87
+ existingFiles?: string[] | undefined;
88
+ includeCi?: boolean | undefined;
89
+ verify?: boolean | undefined;
90
+ author?: AuthorKind | undefined;
91
+ maxSteps?: number | undefined;
92
+ maxDurationMs?: number | undefined;
93
+ contextPacket?: unknown;
94
+ workflowPlan?: unknown;
95
+ explorationBundle?: unknown;
96
+ allowedEvidenceIds?: string[] | undefined;
97
+ }
98
+
99
+ export interface GeneratedScenarioResult {
100
+ scenario: GenerationPlan['scenarios'][number];
101
+ architecture: ArchitecturePlan;
102
+ candidate?: CandidateChange | undefined;
103
+ writeResult?: ArchitectureWriteResult | undefined;
104
+ candidateWriteResult?: CandidateWriteResult | undefined;
105
+ verification?: ExecutionResult | undefined;
106
+ loopState?: 'SUCCESS' | 'AGENT_NO_PROGRESS' | 'AGENT_TIME_BUDGET' | 'MAX_STEPS_EXCEEDED' | undefined;
107
+ stepsCount?: number | undefined;
108
+ qualityGateResult?: QualityGateResult | undefined;
109
+ }
110
+
111
+ export interface GenerationAgentResult {
112
+ status: 'MODEL_NOT_CONFIGURED' | 'ABSTAINED' | 'NEEDS_INPUT' | 'PLANNED' | 'WRITTEN' | 'VERIFIED' | 'VERIFICATION_FAILED' | 'AGENT_NO_PROGRESS';
113
+ provider: 'llm' | 'deterministic-fallback';
114
+ authorKind?: AuthorKind | undefined;
115
+ intelligence: TestIntelligencePlan;
116
+ plan?: GenerationPlan | undefined;
117
+ scenarios: GeneratedScenarioResult[];
118
+ errors: string[];
119
+ }
120
+
121
+ function parseJson(input: unknown): unknown {
122
+ if (typeof input !== 'string') return input;
123
+ const fenced = input.trim().match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1];
124
+ const candidate = (fenced ?? input).trim();
125
+ const start = candidate.indexOf('{');
126
+ const end = candidate.lastIndexOf('}');
127
+ return JSON.parse(start >= 0 && end > start ? candidate.slice(start, end + 1) : candidate);
128
+ }
129
+
130
+ export function parseGenerationPlan(input: unknown): GenerationPlanParseResult {
131
+ try {
132
+ const parsed = GenerationPlanSchema.safeParse(parseJson(input));
133
+ if (!parsed.success) return { isValid: false, errors: parsed.error.issues.map((issue) => issue.message) };
134
+ if (parsed.data.status === 'GENERATE' && parsed.data.scenarios.length === 0) {
135
+ return { isValid: false, errors: ['GENERATE plans must contain at least one scenario'] };
136
+ }
137
+ if (parsed.data.status === 'NEEDS_INPUT' && parsed.data.requiredClarifications.length === 0) {
138
+ return { isValid: false, errors: ['NEEDS_INPUT plans must include requiredClarifications'] };
139
+ }
140
+ return { isValid: true, plan: parsed.data, errors: [] };
141
+ } catch (error) {
142
+ return { isValid: false, errors: [`Generation response is not valid JSON: ${error instanceof Error ? error.message : String(error)}`] };
143
+ }
144
+ }
145
+
146
+ function generationSystemPrompt(): string {
147
+ return 'Return exactly one JSON object conforming to the schema:\n' +
148
+ '{\n' +
149
+ ' "planVersion": "1",\n' +
150
+ ' "status": "GENERATE",\n' +
151
+ ' "reasoningSummary": "string",\n' +
152
+ ' "scenarios": [\n' +
153
+ ' {\n' +
154
+ ' "id": "scenario-1",\n' +
155
+ ' "title": "string",\n' +
156
+ ' "requirementId": "string",\n' +
157
+ ' "steps": [\n' +
158
+ ' { "action": "navigate", "value": "/url" },\n' +
159
+ ' { "action": "fill", "role": "textbox", "name": "Field", "value": "val" },\n' +
160
+ ' { "action": "click", "role": "button", "name": "Button" },\n' +
161
+ ' { "action": "assert-text", "role": "status", "name": "Label", "expectedText": "Expected" }\n' +
162
+ ' ],\n' +
163
+ ' "reasoningSummary": "string",\n' +
164
+ ' "evidenceReferences": []\n' +
165
+ ' }\n' +
166
+ ' ],\n' +
167
+ ' "requiredClarifications": [],\n' +
168
+ ' "evidenceReferences": []\n' +
169
+ '}\n' +
170
+ 'Steps may only use click, fill, navigate, assert-visible, or assert-text. Every scenario MUST contain at least one step in its steps array. Do not return raw code, patches, or unsupported fields.';
171
+ }
172
+
173
+ export interface OpenAiCompatibleGenerationProviderOptions {
174
+ endpoint: string;
175
+ model: string;
176
+ apiKey?: string | undefined;
177
+ timeoutMs?: number | undefined;
178
+ fetchImpl?: typeof fetch | undefined;
179
+ }
180
+
181
+ /** Provider-neutral boundary with an OpenAI-compatible transport implementation. */
182
+ export class OpenAiCompatibleGenerationProvider implements GenerationPlanProvider {
183
+ public constructor(private readonly options: OpenAiCompatibleGenerationProviderOptions) {}
184
+
185
+ public async propose(input: GenerationPlanInput, intelligence: TestIntelligencePlan): Promise<GenerationPlanParseResult> {
186
+ const controller = new AbortController();
187
+ const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 60_000);
188
+ try {
189
+ const response = await (this.options.fetchImpl ?? fetch)(this.options.endpoint, {
190
+ method: 'POST',
191
+ signal: controller.signal,
192
+ headers: {
193
+ 'content-type': 'application/json',
194
+ ...(this.options.apiKey ? { authorization: `Bearer ${this.options.apiKey}` } : {}),
195
+ },
196
+ body: JSON.stringify({
197
+ model: this.options.model,
198
+ temperature: 0,
199
+ response_format: { type: 'json_object' },
200
+ messages: [
201
+ { role: 'system', content: generationSystemPrompt() },
202
+ { role: 'user', content: JSON.stringify({ input, intelligence }) },
203
+ ],
204
+ }),
205
+ });
206
+ if (!response.ok) return { isValid: false, errors: [`Model endpoint returned HTTP ${response.status}`] };
207
+ const payload = await response.json() as { choices?: Array<{ message?: { content?: string | Array<{ text?: string }> | null } }>; output_text?: string };
208
+ const content = payload.output_text
209
+ ?? (typeof payload.choices?.[0]?.message?.content === 'string'
210
+ ? payload.choices[0].message.content
211
+ : payload.choices?.[0]?.message?.content?.map((part) => part.text ?? '').join(''));
212
+ if (!content) return { isValid: false, errors: ['Model endpoint returned no generation plan content'] };
213
+ return parseGenerationPlan(content);
214
+ } catch (error) {
215
+ return { isValid: false, errors: [error instanceof Error && error.name === 'AbortError' ? 'Generation model request timed out' : `Generation model request failed: ${error instanceof Error ? error.message : String(error)}`] };
216
+ } finally {
217
+ clearTimeout(timeout);
218
+ }
219
+ }
220
+ }
221
+
222
+ function configuredGenerationProvider(): GenerationPlanProvider | undefined {
223
+ const liveAllowed = !process.env.VITEST || process.env.PIE_LIVE_LLM_TESTS === '1';
224
+ const apiKey = liveAllowed ? (process.env.PIE_LLM_API_KEY ?? process.env.OPENAI_API_KEY ?? process.env.DEEPSEEK_API_KEY) : undefined;
225
+ const endpoint = process.env.PIE_LLM_ENDPOINT ?? (process.env.OPENAI_API_KEY ? 'https://api.openai.com/v1/chat/completions' : (apiKey ? 'https://api.deepseek.com/chat/completions' : undefined));
226
+ const model = process.env.PIE_LLM_MODEL ?? (process.env.OPENAI_API_KEY ? 'gpt-4o' : (apiKey ? 'deepseek-chat' : undefined));
227
+ if (!endpoint || !model) return undefined;
228
+ return new OpenAiCompatibleGenerationProvider({
229
+ endpoint,
230
+ model,
231
+ ...(apiKey ? { apiKey } : {}),
232
+ });
233
+ }
234
+
235
+ function validateGeneratedScenario(
236
+ scenario: GenerationPlan['scenarios'][number],
237
+ requirementIds: Set<string>,
238
+ ): string[] {
239
+ const errors: string[] = [];
240
+ if (!requirementIds.has(scenario.requirementId)) errors.push(`Scenario '${scenario.id}' references unknown requirement '${scenario.requirementId}'`);
241
+ for (const [index, step] of scenario.steps.entries()) {
242
+ const hasSemanticTarget = step.action === 'fill'
243
+ ? Boolean(step.name || (step.role && step.name))
244
+ : Boolean(step.role && step.name);
245
+ const hasExplicitTarget = Boolean(step.selector);
246
+ if ((step.action === 'click' || step.action === 'fill') && !hasSemanticTarget && !hasExplicitTarget) {
247
+ errors.push(`Scenario '${scenario.id}' step ${index + 1} requires role/name or an explicit selector`);
248
+ }
249
+ if (step.action === 'assert-visible' && !hasSemanticTarget && !hasExplicitTarget) {
250
+ errors.push(`Scenario '${scenario.id}' visibility assertion requires role/name or an explicit selector`);
251
+ }
252
+ if (step.action === 'assert-text') {
253
+ const text = step.expectedText?.trim() || step.value?.trim();
254
+ if (!text) {
255
+ errors.push(`Scenario '${scenario.id}' text assertion requires expectedText or value`);
256
+ } else if (!step.expectedText) {
257
+ step.expectedText = text;
258
+ }
259
+ }
260
+ }
261
+ return errors;
262
+ }
263
+
264
+ export async function runGenerationAgent(input: GenerationAgentInput): Promise<GenerationAgentResult> {
265
+ const intelligence = buildTestIntelligencePlan(input.requirements, input.existingTests ?? []);
266
+ const profile = input.repositoryProfile ?? (input.mode === 'brownfield' ? await buildRepositoryProfile(input.workspaceRoot ?? process.cwd()) : undefined);
267
+ const provider = input.provider ?? configuredGenerationProvider();
268
+ if (input.verify === true && input.write !== true) {
269
+ return { status: 'ABSTAINED', provider: provider ? 'llm' : 'deterministic-fallback', intelligence, scenarios: [], errors: ['Generation verification requires --write so the generated spec exists in the target workspace'] };
270
+ }
271
+
272
+ const authorKind: AuthorKind = input.author ?? 'hybrid';
273
+ const author: Author = input.authorImpl ?? (
274
+ authorKind === 'template'
275
+ ? new TemplateAuthor()
276
+ : authorKind === 'llm'
277
+ ? new LlmAuthor({ provider: input.fullCodeProvider })
278
+ : new HybridAuthor()
279
+ );
280
+
281
+ // If Tier-L2 fullCodeProvider is supplied with author='llm' and no custom step provider,
282
+ // allow direct full-code generation without forcing the 5-verb step planner first.
283
+ const effectiveProvider = (authorKind === 'llm' && input.fullCodeProvider && !input.provider) ? undefined : provider;
284
+
285
+ let generatedPlan: GenerationPlan;
286
+ let providerMode: 'llm' | 'deterministic-fallback' = 'llm';
287
+ if (effectiveProvider) {
288
+ const response = await effectiveProvider.propose({
289
+ requirements: input.requirements,
290
+ existingTests: input.existingTests,
291
+ mode: input.mode,
292
+ repositoryProfile: profile,
293
+ baseUrl: input.baseUrl,
294
+ suggestedSteps: input.suggestedSteps,
295
+ }, intelligence);
296
+ if (!response.isValid || !response.plan) return { status: 'ABSTAINED', provider: 'llm', intelligence, scenarios: [], errors: response.errors };
297
+ if (response.plan.status === 'ABSTAIN') return { status: 'ABSTAINED', provider: 'llm', intelligence, plan: response.plan, scenarios: [], errors: [response.plan.reasoningSummary] };
298
+ if (response.plan.status === 'NEEDS_INPUT') return { status: 'NEEDS_INPUT', provider: 'llm', intelligence, plan: response.plan, scenarios: [], errors: response.plan.requiredClarifications };
299
+ generatedPlan = response.plan;
300
+ } else {
301
+ providerMode = authorKind === 'llm' && input.fullCodeProvider ? 'llm' : 'deterministic-fallback';
302
+ generatedPlan = {
303
+ planVersion: '1',
304
+ status: 'GENERATE',
305
+ reasoningSummary: authorKind === 'llm' && input.fullCodeProvider
306
+ ? 'Tier-L2 full-code provider configured; synthesizing scenarios directly via LlmAuthor.'
307
+ : 'No LLM provider is configured; generated a conservative deterministic scaffold from the supplied requirements.',
308
+ scenarios: input.requirements.map((requirement) => ({
309
+ id: `scenario-${requirement.id}`,
310
+ title: requirement.title,
311
+ requirementId: requirement.id,
312
+ steps: input.suggestedSteps && input.suggestedSteps.length > 0
313
+ ? input.suggestedSteps
314
+ : [{ action: 'navigate' as const }, { action: 'assert-visible' as const, role: 'heading', name: requirement.title }],
315
+ reasoningSummary: 'Scenario derived directly from the supplied requirement; business assertions require review.',
316
+ evidenceReferences: [`requirement:${requirement.id}`],
317
+ })),
318
+ requiredClarifications: [],
319
+ evidenceReferences: input.requirements.map((requirement) => `requirement:${requirement.id}`),
320
+ };
321
+ }
322
+
323
+ const requirementIds = new Set(input.requirements.map((requirement) => requirement.id));
324
+ const scenarioIds = new Set<string>();
325
+ const scenarioValidationErrors = generatedPlan.scenarios.flatMap((scenario) => {
326
+ const errors = scenarioIds.has(scenario.id) ? [`Duplicate generated scenario id '${scenario.id}'`] : [];
327
+ scenarioIds.add(scenario.id);
328
+ return [...errors, ...validateGeneratedScenario(scenario, requirementIds)];
329
+ });
330
+ if (scenarioValidationErrors.length > 0) {
331
+ return { status: 'ABSTAINED', provider: providerMode, intelligence, plan: generatedPlan, scenarios: [], errors: scenarioValidationErrors };
332
+ }
333
+
334
+ const scenarios: GeneratedScenarioResult[] = [];
335
+ for (const scenario of generatedPlan.scenarios) {
336
+ const maxSteps = input.maxSteps ?? 3;
337
+ const maxDurationMs = input.maxDurationMs ?? 120_000;
338
+ const startTime = Date.now();
339
+ const seenSignatures = new Set<string>();
340
+
341
+ let candidate: CandidateChange | undefined;
342
+ let gateResult: QualityGateResult | undefined;
343
+ let loopState: 'SUCCESS' | 'AGENT_NO_PROGRESS' | 'AGENT_TIME_BUDGET' | 'MAX_STEPS_EXCEEDED' = 'SUCCESS';
344
+ let stepCount = 0;
345
+
346
+ for (let stepIndex = 0; stepIndex < maxSteps; stepIndex++) {
347
+ stepCount++;
348
+ if (Date.now() - startTime >= maxDurationMs) {
349
+ loopState = 'AGENT_TIME_BUDGET';
350
+ break;
351
+ }
352
+
353
+ candidate = await author.propose({
354
+ featureName: scenario.title,
355
+ mode: input.mode,
356
+ baseUrl: input.baseUrl,
357
+ requirements: input.requirements.find((r) => r.id === scenario.requirementId)?.description
358
+ ?? input.requirements.find((r) => r.id === scenario.requirementId)?.title,
359
+ steps: scenario.steps as GeneratedStep[],
360
+ profile,
361
+ existingFiles: input.existingFiles,
362
+ exemplars: profile?.exemplars,
363
+ contextPacket: input.contextPacket,
364
+ workflowPlan: input.workflowPlan,
365
+ explorationBundle: input.explorationBundle,
366
+ scenarios: generatedPlan.scenarios,
367
+ allowedEvidenceIds: input.allowedEvidenceIds,
368
+ fullCodeProvider: input.fullCodeProvider,
369
+ });
370
+
371
+ if (candidate.status === 'NEEDS_INPUT') {
372
+ return {
373
+ status: 'NEEDS_INPUT',
374
+ provider: providerMode,
375
+ authorKind,
376
+ intelligence,
377
+ plan: generatedPlan,
378
+ scenarios,
379
+ errors: candidate.requiredClarifications?.length ? candidate.requiredClarifications : [candidate.rationale],
380
+ };
381
+ }
382
+
383
+ if (candidate.status === 'ABSTAIN' && candidate.fullCodeAuthoring) {
384
+ gateResult = {
385
+ passed: false,
386
+ duplicateSelectors: [],
387
+ locatorPolicyViolations: [],
388
+ assertionStrengthPassed: false,
389
+ changedLines: 0,
390
+ violations: candidate.validationErrors?.length ? candidate.validationErrors : [candidate.rationale],
391
+ };
392
+ } else {
393
+ gateResult = QualityGate.evaluate({
394
+ symbols: candidate.symbols,
395
+ files: candidate.files,
396
+ locatorPolicy: profile?.identifierConventions?.recommendedLocatorPolicy,
397
+ });
398
+
399
+ if (candidate.fullCodeAuthoring) {
400
+ for (const spec of candidate.files.filter((f) => /\.(?:spec|test)\.(?:ts|tsx|js|jsx|mjs)$/i.test(f.relativePath))) {
401
+ const aqg = AssertionQualityGate.evaluate(spec.content, scenario.title);
402
+ if (!aqg.passed) {
403
+ gateResult.passed = false;
404
+ gateResult.assertionStrengthPassed = false;
405
+ gateResult.violations.push(...aqg.violations);
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ const signature = `${candidate.symbols.pageClassName}:${candidate.symbols.symbols.map((s) => `${s.property}:${s.method}`).join(',')}:${gateResult.violations.join(';')}`;
412
+
413
+ if (seenSignatures.has(signature)) {
414
+ loopState = 'AGENT_NO_PROGRESS';
415
+ break;
416
+ }
417
+ seenSignatures.add(signature);
418
+
419
+ if (gateResult.passed) {
420
+ loopState = 'SUCCESS';
421
+ break;
422
+ }
423
+
424
+ if (stepIndex === maxSteps - 1) {
425
+ loopState = 'MAX_STEPS_EXCEEDED';
426
+ }
427
+ }
428
+
429
+ const preserveCandidateFiles = Boolean(
430
+ candidate &&
431
+ candidate.files.length > 0 &&
432
+ (candidate.fullCodeAuthoring === true || candidate.authorKind === 'llm'),
433
+ );
434
+
435
+ const architecture: ArchitecturePlan = preserveCandidateFiles && candidate
436
+ ? {
437
+ mode: input.mode,
438
+ status: candidate.status === 'ABSTAIN' ? 'ABSTAIN' : 'READY',
439
+ featureName: scenario.title,
440
+ slug: toSlug(scenario.title),
441
+ conventions: {
442
+ testDirectory: profile?.paths?.testDirectory ?? 'tests',
443
+ pageObjectDirectory: profile?.paths?.pageObjectDirectory ?? 'tests/pages',
444
+ fixtureDirectory: profile?.paths?.fixtureDirectory ?? 'tests/fixtures',
445
+ preferredLocator: profile?.locatorConventions.preferred ?? 'getByRole',
446
+ usesCustomFixtures: profile?.structure.usesCustomFixtures ?? false,
447
+ usesStorageState: profile?.structure.usesStorageState ?? false,
448
+ },
449
+ assumptions: candidate.assumptions ?? [],
450
+ warnings: candidate.validationErrors ?? [],
451
+ files: candidate.files.map((file) => ({
452
+ relativePath: file.relativePath,
453
+ content: file.content,
454
+ purpose: file.purpose ?? file.rationale ?? 'Author-generated file',
455
+ action: file.action ?? 'CREATE',
456
+ })),
457
+ namePlan: candidate.symbols,
458
+ namingLintReport: NamingLinter.lint(candidate.symbols),
459
+ }
460
+ : planArchitecture({
461
+ featureName: scenario.title,
462
+ mode: input.mode,
463
+ baseUrl: input.baseUrl,
464
+ requirements: input.requirements.find((requirement) => requirement.id === scenario.requirementId)?.description
465
+ ?? input.requirements.find((requirement) => requirement.id === scenario.requirementId)?.title,
466
+ steps: scenario.steps as GeneratedStep[],
467
+ profile,
468
+ existingFiles: input.existingFiles,
469
+ includeCi: input.includeCi,
470
+ namePlan: candidate?.symbols,
471
+ });
472
+
473
+ let writeResult: ArchitectureWriteResult | undefined;
474
+ let candidateWriteResult: CandidateWriteResult | undefined;
475
+ if (input.write === true && loopState === 'SUCCESS') {
476
+ const workspaceRoot = input.workspaceRoot ?? process.cwd();
477
+ if (preserveCandidateFiles && candidate) {
478
+ candidateWriteResult = await CandidateWriter.apply(workspaceRoot, candidate, {
479
+ allowModify: input.allowModify ?? true,
480
+ validateLocalImports: false,
481
+ });
482
+ if (!candidateWriteResult.success) {
483
+ return {
484
+ status: 'ABSTAINED',
485
+ provider: providerMode,
486
+ authorKind,
487
+ intelligence,
488
+ plan: generatedPlan,
489
+ scenarios,
490
+ errors: candidateWriteResult.errors,
491
+ };
492
+ }
493
+ writeResult = {
494
+ written: candidateWriteResult.written,
495
+ skipped: candidateWriteResult.skipped,
496
+ modified: candidateWriteResult.modified,
497
+ };
498
+ } else {
499
+ writeResult = await writeArchitecturePlan(workspaceRoot, architecture);
500
+ }
501
+ }
502
+
503
+ let verification: ExecutionResult | undefined;
504
+ if (input.verify === true && loopState === 'SUCCESS') {
505
+ const spec = architecture.files.find((file) => /\.(?:spec|test)\.(?:ts|tsx|js|jsx|mjs)$/i.test(file.relativePath));
506
+ if (!spec) throw new Error(`Generated scenario '${scenario.id}' did not produce a spec file`);
507
+ verification = await ExecutionRunner.run('npx', {
508
+ cwd: input.workspaceRoot ?? process.cwd(),
509
+ args: ['playwright', 'test', spec.relativePath, '--reporter=line'],
510
+ timeoutMs: 120_000,
511
+ });
512
+ }
513
+
514
+ scenarios.push({
515
+ scenario,
516
+ architecture,
517
+ ...(candidate ? { candidate } : {}),
518
+ ...(writeResult ? { writeResult } : {}),
519
+ ...(candidateWriteResult ? { candidateWriteResult } : {}),
520
+ ...(verification ? { verification } : {}),
521
+ loopState,
522
+ stepsCount: stepCount,
523
+ ...(gateResult ? { qualityGateResult: gateResult } : {}),
524
+ });
525
+ }
526
+
527
+ const anyNoProgress = scenarios.some((s) => s.loopState === 'AGENT_NO_PROGRESS');
528
+ const anyGateFailed = scenarios.some((s) => s.qualityGateResult && !s.qualityGateResult.passed);
529
+ const anyVerificationFailed = scenarios.some((s) => s.verification && s.verification.exitCode !== 0);
530
+
531
+ const errors: string[] = [];
532
+ if (anyNoProgress) {
533
+ errors.push('Generation agent detected no progress: author proposed duplicate invalid states.');
534
+ }
535
+ if (anyGateFailed) {
536
+ errors.push(...scenarios.flatMap((s) => s.qualityGateResult?.violations ?? []));
537
+ }
538
+
539
+ let status: GenerationAgentResult['status'];
540
+ if (anyNoProgress) {
541
+ status = 'AGENT_NO_PROGRESS';
542
+ } else if (anyGateFailed) {
543
+ status = 'ABSTAINED';
544
+ } else if (input.verify === true) {
545
+ status = anyVerificationFailed ? 'VERIFICATION_FAILED' : 'VERIFIED';
546
+ } else if (input.write === true) {
547
+ status = 'WRITTEN';
548
+ } else {
549
+ status = 'PLANNED';
550
+ }
551
+
552
+ return {
553
+ status,
554
+ provider: providerMode,
555
+ authorKind,
556
+ intelligence,
557
+ plan: generatedPlan,
558
+ scenarios,
559
+ errors,
560
+ };
561
+ }