@ggui-ai/ui-gen 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/generation-dispatch.d.ts +18 -1
- package/dist/adapters/generation-dispatch.js +819 -1577
- package/dist/adapters/generation-dispatch.js.map +1 -1
- package/dist/adapters/index.js +28 -11
- package/dist/adapters/index.js.map +1 -1
- package/dist/advanced/index.d.ts +5 -3
- package/dist/advanced/index.js +838 -1579
- package/dist/advanced/index.js.map +1 -1
- package/dist/blueprint-validator.js +109 -3
- package/dist/blueprint-validator.js.map +1 -1
- package/dist/boilerplate.js +17 -4
- package/dist/boilerplate.js.map +1 -1
- package/dist/check/index.d.ts +1 -1
- package/dist/check/index.js +11 -1
- package/dist/check/index.js.map +1 -1
- package/dist/compose.js +5 -5
- package/dist/compose.js.map +1 -1
- package/dist/{create-ui-generator-GX7tvbyH.d.ts → create-ui-generator-CF0EL0Li.d.ts} +26 -1
- package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
- package/dist/evaluation/axis-checks/index.d.ts +3 -3
- package/dist/evaluation/axis-checks/index.js +133 -1
- package/dist/evaluation/axis-checks/index.js.map +1 -1
- package/dist/evaluation/axis-checks/registry.d.ts +1 -1
- package/dist/evaluation/axis-checks/registry.js +133 -1
- package/dist/evaluation/axis-checks/registry.js.map +1 -1
- package/dist/evaluation/index.d.ts +46 -3
- package/dist/evaluation/index.js +410 -24
- package/dist/evaluation/index.js.map +1 -1
- package/dist/fragments/index.js +5 -5
- package/dist/fragments/index.js.map +1 -1
- package/dist/harness/check/runtime-render/index.d.ts +2 -2
- package/dist/harness/check/runtime-render/index.js +110 -3
- package/dist/harness/check/runtime-render/index.js.map +1 -1
- package/dist/harness/index.d.ts +5 -126
- package/dist/harness/index.js +292 -32
- package/dist/harness/index.js.map +1 -1
- package/dist/harness/llm-trace-sink.d.ts +1 -1
- package/dist/harness/llm-trace-sink.js.map +1 -1
- package/dist/harness/prompts.js +17 -3
- package/dist/harness/prompts.js.map +1 -1
- package/dist/harness/result-types.d.ts +1 -1
- package/dist/harness/runtime.d.ts +17 -1
- package/dist/harness/runtime.js +23 -8
- package/dist/harness/runtime.js.map +1 -1
- package/dist/harness/types-public.d.ts +1 -1
- package/dist/harness/types-public.js +22 -9
- package/dist/harness/types-public.js.map +1 -1
- package/dist/harness/validator-trace-sink.d.ts +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +838 -1579
- package/dist/index.js.map +1 -1
- package/dist/llm-router-BEVtbAb9.d.ts +213 -0
- package/dist/patch.d.ts +1 -1
- package/dist/provider-adapter.d.ts +16 -6
- package/dist/provider-adapter.js.map +1 -1
- package/dist/providers/index.d.ts +54 -99
- package/dist/providers/index.js +13 -8
- package/dist/providers/index.js.map +1 -1
- package/dist/{render-check-CNZjHD5g.d.ts → render-check-BNYGVDJd.d.ts} +1 -1
- package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
- package/dist/validation/index.js +2 -2
- package/dist/validation/index.js.map +1 -1
- package/dist/workflows.d.ts +1 -1
- package/package.json +18 -18
- package/src/adapters/generation-dispatch.ts +28 -2
- package/src/adapters/openrouter/client.ts +2 -2
- package/src/adapters/openrouter/types.ts +10 -0
- package/src/boilerplate/generate.ts +10 -2
- package/src/boilerplate/json-schema-ts.ts +22 -2
- package/src/boilerplate/system-prompt.ts +4 -2
- package/src/compile.ts +1 -1
- package/src/contract-context.ts +10 -2
- package/src/create-ui-generator.ts +58 -11
- package/src/design-system-docs.ts +6 -4
- package/src/evaluation/axis-checks/checks/state-merge.ts +1 -1
- package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
- package/src/evaluation/axis-checks/checks/universal.ts +39 -0
- package/src/evaluation/axis-checks/registry.ts +2 -0
- package/src/evaluation/index.ts +11 -0
- package/src/evaluation/llm-evaluator.ts +26 -3
- package/src/evaluation/visual-evaluator.ts +49 -66
- package/src/fragments/realtime.ts +3 -3
- package/src/fragments/state.ts +2 -2
- package/src/harness/check/runtime-render/adapter.ts +18 -0
- package/src/harness/check/runtime-render/index.ts +8 -0
- package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
- package/src/harness/check/runtime-render/production-act-shim.ts +158 -0
- package/src/harness/check/runtime-render/render-check.ts +72 -1
- package/src/harness/coding/init-session.ts +4 -1
- package/src/harness/coding/run-eval-round.ts +67 -5
- package/src/harness/index.ts +1 -1
- package/src/harness/llm-router.ts +451 -27
- package/src/harness/llm-trace-sink.ts +2 -1
- package/src/harness/runtime.ts +15 -0
- package/src/index.ts +5 -5
- package/src/provider-adapter.ts +16 -6
- package/src/providers/bedrock.ts +89 -26
- package/src/providers/index.ts +16 -4
- package/src/tools/render-check.ts +19 -7
- package/src/validation/ui-compiler.ts +2 -2
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { AxisCheck } from "./types.js";
|
|
7
7
|
import { UNIVERSAL_CHECKS } from "./checks/universal.js";
|
|
8
8
|
import { STATE_MERGE_CHECKS } from "./checks/state-merge.js";
|
|
9
|
+
import { STATE_UI_AFFORDANCE_CHECKS } from "./checks/state-ui-affordance.js";
|
|
9
10
|
import { REALTIME_CHECKS } from "./checks/realtime.js";
|
|
10
11
|
import { WRITES_CHECKS } from "./checks/writes.js";
|
|
11
12
|
import { STATE_PAYLOAD_CHECKS } from "./checks/state-payload.js";
|
|
@@ -15,6 +16,7 @@ import { EXTRA_CHECKS } from "./extras.js";
|
|
|
15
16
|
export const REGISTRY: readonly AxisCheck[] = [
|
|
16
17
|
...UNIVERSAL_CHECKS,
|
|
17
18
|
...STATE_MERGE_CHECKS,
|
|
19
|
+
...STATE_UI_AFFORDANCE_CHECKS,
|
|
18
20
|
...REALTIME_CHECKS,
|
|
19
21
|
...WRITES_CHECKS,
|
|
20
22
|
...STATE_PAYLOAD_CHECKS,
|
package/src/evaluation/index.ts
CHANGED
|
@@ -71,3 +71,14 @@ export type {
|
|
|
71
71
|
} from './types.js';
|
|
72
72
|
export { runAxisChecks } from './axis-checks/index.js';
|
|
73
73
|
export type { RunAxisChecksInput } from './axis-checks/index.js';
|
|
74
|
+
|
|
75
|
+
// The visual judge's raw multimodal call — arbitrary prompt + screenshot
|
|
76
|
+
// Buffer, routed through the same `createVisionAgent`/`apiCall()` choke
|
|
77
|
+
// point as every in-loop evaluation call (the #504 contract). Exposed so
|
|
78
|
+
// standalone visual-judging instruments (host-fit probes, external
|
|
79
|
+
// benches) can reuse the judging machinery WITHOUT entering the in-loop
|
|
80
|
+
// rubric — scoring generation on criteria the generator was never taught
|
|
81
|
+
// is a constraint-alignment violation, so out-of-loop judges get this
|
|
82
|
+
// seam instead of a rubric extension.
|
|
83
|
+
export { callMultimodalLLM } from './visual-evaluator.js';
|
|
84
|
+
export type { VisualEvalConfig } from './visual-evaluator.js';
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import type { EvalIssue, EvalResult, EvalCategory } from './types-public.js';
|
|
18
18
|
import { createAgent } from '../harness/llm-router';
|
|
19
|
-
import type { LLMToolDef, LLMAgent } from '../harness/llm-router';
|
|
19
|
+
import type { AgentConfig, LLMToolDef, LLMAgent } from '../harness/llm-router';
|
|
20
20
|
import type { JsonObject } from '@ggui-ai/protocol';
|
|
21
21
|
import { getCriterionById } from './types-public.js';
|
|
22
22
|
|
|
@@ -64,6 +64,21 @@ export interface LLMEvalContext {
|
|
|
64
64
|
export interface LLMEvalConfig {
|
|
65
65
|
provider: 'claude' | 'openai' | 'google' | 'openrouter';
|
|
66
66
|
model?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Explicit per-call credentials/routing (#484) — see
|
|
69
|
+
* `AgentConfig.routeOverride`. Threaded onto the agent this
|
|
70
|
+
* evaluator constructs so it never falls back to reading
|
|
71
|
+
* `process.env`. Absent (default) preserves existing behavior.
|
|
72
|
+
*/
|
|
73
|
+
routeOverride?: AgentConfig['routeOverride'];
|
|
74
|
+
/**
|
|
75
|
+
* Provider-429-retry observer (#489) — see `AgentConfig.onRetry`.
|
|
76
|
+
* Threaded onto the agent this evaluator constructs so a rate-limited
|
|
77
|
+
* retry inside an evaluation turn's `apiCall()` reaches the same
|
|
78
|
+
* caller the coding leg reaches. Absent (default) preserves existing
|
|
79
|
+
* behavior (retries still happen; no observer fires).
|
|
80
|
+
*/
|
|
81
|
+
onRetry?: AgentConfig['onRetry'];
|
|
67
82
|
}
|
|
68
83
|
|
|
69
84
|
/** A request-specific evaluation criterion generated by the LLM. */
|
|
@@ -745,8 +760,16 @@ export async function runLLMEvaluation(
|
|
|
745
760
|
.map((line, i) => `${String(i + 1).padStart(4)} | ${line}`)
|
|
746
761
|
.join('\n');
|
|
747
762
|
|
|
748
|
-
// Create a single agent — all calls share the same client + cache
|
|
749
|
-
|
|
763
|
+
// Create a single agent — all calls share the same client + cache.
|
|
764
|
+
// Pass the full config (not just the bare provider string) so a
|
|
765
|
+
// caller-supplied `routeOverride` (#484) reaches the constructed
|
|
766
|
+
// `LLMAgent` instance and it never falls back to `process.env`.
|
|
767
|
+
const agent = createAgent({
|
|
768
|
+
provider: routerProvider,
|
|
769
|
+
model,
|
|
770
|
+
routeOverride: config.routeOverride,
|
|
771
|
+
onRetry: config.onRetry,
|
|
772
|
+
});
|
|
750
773
|
|
|
751
774
|
// Dynamic criteria removed — they generated overly strict edge-case checks
|
|
752
775
|
// that wasted coding turns on nitpicks. Will be replaced by a holistic
|
|
@@ -16,6 +16,7 @@ import { resolve, dirname } from 'path';
|
|
|
16
16
|
import { fileURLToPath } from 'url';
|
|
17
17
|
import { writeFileSync, mkdirSync, unlinkSync } from 'fs';
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
|
+
import { createVisionAgent, type AgentConfig } from '../harness/llm-router';
|
|
19
20
|
import type { EvaluationResult, EvaluationIssue, DimensionScores } from './types';
|
|
20
21
|
import type { EvalIssue } from './types-public.js';
|
|
21
22
|
|
|
@@ -34,6 +35,20 @@ export interface VisualEvalConfig {
|
|
|
34
35
|
sampleProps?: Record<string, unknown>;
|
|
35
36
|
/** Viewport dimensions for screenshot */
|
|
36
37
|
viewport?: { width: number; height: number };
|
|
38
|
+
/**
|
|
39
|
+
* Optional provider-routing override — see
|
|
40
|
+
* `AgentConfig.routeOverride`. Threaded onto the agent this
|
|
41
|
+
* evaluator constructs so its multimodal call never falls back to
|
|
42
|
+
* `process.env` for credentials or model routing.
|
|
43
|
+
*/
|
|
44
|
+
routeOverride?: AgentConfig['routeOverride'];
|
|
45
|
+
/**
|
|
46
|
+
* Provider-429-retry observer — see `AgentConfig.onRetry`.
|
|
47
|
+
* Without it, a rate-limited retry inside the evaluation call
|
|
48
|
+
* happens (the agent's retry loop is unconditional) but never
|
|
49
|
+
* reaches the caller's structured log.
|
|
50
|
+
*/
|
|
51
|
+
onRetry?: AgentConfig['onRetry'];
|
|
37
52
|
}
|
|
38
53
|
|
|
39
54
|
export interface VisualEvalContext {
|
|
@@ -185,7 +200,11 @@ async function captureScreenshot(
|
|
|
185
200
|
const browser = await puppeteer.default.launch(launchOptions);
|
|
186
201
|
try {
|
|
187
202
|
const page = await browser.newPage();
|
|
188
|
-
|
|
203
|
+
// puppeteer ≥24.43 narrowed setContent's waitUntil to
|
|
204
|
+
// 'load' | 'domcontentloaded'; waitForNetworkIdle (0 connections
|
|
205
|
+
// for 500ms) reproduces the old 'networkidle0' semantics.
|
|
206
|
+
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
|
|
207
|
+
await page.waitForNetworkIdle({ idleTime: 500, timeout: 15000 }).catch(() => {});
|
|
189
208
|
await page.waitForSelector('#root > *', { timeout: 10000 }).catch(() => {});
|
|
190
209
|
// Wait a bit for CSS/fonts to settle
|
|
191
210
|
await new Promise(r => setTimeout(r, 1000));
|
|
@@ -243,75 +262,37 @@ Respond with ONLY a JSON object (no markdown, no explanation):
|
|
|
243
262
|
"critique": "<2-3 sentences: what's good, what needs improvement, does it achieve the user's goal?>"
|
|
244
263
|
}`;
|
|
245
264
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
265
|
+
/**
|
|
266
|
+
* Send the screenshot to the configured multimodal provider through
|
|
267
|
+
* the shared `LLMAgent` machinery. Routing through `createVisionAgent`
|
|
268
|
+
* (rather than constructing provider SDK clients inline) is what puts
|
|
269
|
+
* this call inside the same `apiCall()` choke point as every other
|
|
270
|
+
* completion: provider 429s retry with `Retry-After` honoring, the
|
|
271
|
+
* caller's `onRetry` observer sees each retry, and `routeOverride`
|
|
272
|
+
* governs credentials and model routing (including the Anthropic
|
|
273
|
+
* Bedrock path) instead of `process.env`.
|
|
274
|
+
*/
|
|
252
275
|
async function callMultimodalLLM(
|
|
253
|
-
|
|
276
|
+
config: VisualEvalConfig,
|
|
254
277
|
model: string,
|
|
255
278
|
prompt: string,
|
|
256
279
|
screenshot: Buffer,
|
|
257
280
|
originalPrompt: string,
|
|
258
|
-
)
|
|
281
|
+
) {
|
|
259
282
|
const userPrompt = `## Original Request\n${originalPrompt}\n\nEvaluate the screenshot of the generated component.`;
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
baseURL: 'https://api.anthropic.com',
|
|
266
|
-
});
|
|
267
|
-
const response = await client.messages.create({
|
|
268
|
-
model: model.startsWith('anthropic/') ? model.slice(10) : model,
|
|
269
|
-
max_tokens: 1500,
|
|
270
|
-
system: prompt,
|
|
271
|
-
messages: [{
|
|
272
|
-
role: 'user',
|
|
273
|
-
content: [
|
|
274
|
-
{
|
|
275
|
-
type: 'image',
|
|
276
|
-
source: {
|
|
277
|
-
type: 'base64',
|
|
278
|
-
media_type: 'image/png',
|
|
279
|
-
data: screenshot.toString('base64'),
|
|
280
|
-
},
|
|
281
|
-
},
|
|
282
|
-
{ type: 'text', text: userPrompt },
|
|
283
|
-
],
|
|
284
|
-
}],
|
|
285
|
-
});
|
|
286
|
-
return {
|
|
287
|
-
text: response.content.filter(b => b.type === 'text').map(b => b.text).join(''),
|
|
288
|
-
inputTokens: response.usage.input_tokens,
|
|
289
|
-
outputTokens: response.usage.output_tokens,
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
// Google Gemini
|
|
294
|
-
const { GoogleGenAI } = await import('@google/genai');
|
|
295
|
-
const client = new GoogleGenAI({
|
|
296
|
-
apiKey: process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
|
|
297
|
-
});
|
|
298
|
-
const response = await client.models.generateContent({
|
|
299
|
-
model: model.startsWith('google/') ? model.slice(7) : model,
|
|
300
|
-
contents: [{
|
|
301
|
-
role: 'user',
|
|
302
|
-
parts: [
|
|
303
|
-
{ inlineData: { mimeType: 'image/png', data: screenshot.toString('base64') } },
|
|
304
|
-
{ text: userPrompt },
|
|
305
|
-
],
|
|
306
|
-
}],
|
|
307
|
-
config: { systemInstruction: prompt },
|
|
283
|
+
const agent = createVisionAgent({
|
|
284
|
+
provider: config.provider === 'claude' ? 'anthropic' : config.provider,
|
|
285
|
+
model,
|
|
286
|
+
routeOverride: config.routeOverride,
|
|
287
|
+
onRetry: config.onRetry,
|
|
308
288
|
});
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
289
|
+
return agent.callVision(
|
|
290
|
+
model,
|
|
291
|
+
prompt,
|
|
292
|
+
userPrompt,
|
|
293
|
+
{ mediaType: 'image/png', base64: screenshot.toString('base64') },
|
|
294
|
+
1500,
|
|
295
|
+
);
|
|
315
296
|
}
|
|
316
297
|
|
|
317
298
|
// ---------------------------------------------------------------------------
|
|
@@ -354,7 +335,7 @@ export async function runVisualEvaluation(
|
|
|
354
335
|
// Send to multimodal LLM
|
|
355
336
|
const model = config.model ?? getDefaultVisualModel(config.provider);
|
|
356
337
|
const response = await callMultimodalLLM(
|
|
357
|
-
config
|
|
338
|
+
config,
|
|
358
339
|
model,
|
|
359
340
|
VISUAL_EVAL_PROMPT,
|
|
360
341
|
screenshot,
|
|
@@ -373,9 +354,11 @@ export async function runVisualEvaluation(
|
|
|
373
354
|
}
|
|
374
355
|
|
|
375
356
|
/**
|
|
376
|
-
* Exported for testing — bundles and builds the HTML for rendering
|
|
357
|
+
* Exported for testing — bundles and builds the HTML for rendering;
|
|
358
|
+
* `callMultimodalLLM` so the #504 routing contract (config →
|
|
359
|
+
* `createVisionAgent`) is assertable without a screenshot pipeline.
|
|
377
360
|
*/
|
|
378
|
-
export { bundleForRendering, buildRenderHTML };
|
|
361
|
+
export { bundleForRendering, buildRenderHTML, callMultimodalLLM };
|
|
379
362
|
|
|
380
363
|
// ---------------------------------------------------------------------------
|
|
381
364
|
// Response parsing
|
|
@@ -16,14 +16,14 @@ export const realtimeFragments: Record<string, HarnessFragment> = {
|
|
|
16
16
|
value: "merge",
|
|
17
17
|
cacheTier: "axisDelta",
|
|
18
18
|
promptText:
|
|
19
|
-
"## Realtime: merge\nStream events carry an id; merge into LOCAL STATE. Subscribing alone is not enough — the local list must update on each event for the DOM to re-render.\n\n```tsx\nconst stream = useStream<UpdateT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => prev.map((it) => it.id === stream.latest!.id ? { ...it, ...stream.latest } : it));\n}, [stream.latest]);\n// Render `items.map(...)` — NOT props.items, NOT stream.all.\n```\n\nNever append — stream=merge means the entity already exists locally.",
|
|
19
|
+
"## Realtime: merge\nStream events carry an id; merge into LOCAL STATE. Subscribing alone is not enough — the local list must update on each event for the DOM to re-render.\n\n```tsx\nconst stream = useStream<UpdateT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => setItems(props.items), [props.items]); // re-seed on ggui_amend repaint — props stay the source of truth\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => prev.map((it) => it.id === stream.latest!.id ? { ...it, ...stream.latest } : it));\n}, [stream.latest]);\n// Render `items.map(...)` — NOT props.items, NOT stream.all.\n```\n\nNever append — stream=merge means the entity already exists locally.",
|
|
20
20
|
},
|
|
21
21
|
append: {
|
|
22
22
|
axis: "realtime",
|
|
23
23
|
value: "append",
|
|
24
24
|
cacheTier: "axisDelta",
|
|
25
25
|
promptText:
|
|
26
|
-
"## Realtime: append\nStream events are new entities; APPEND to local state. Subscribing alone is not enough — the local list must update for the DOM to re-render.\n\n```tsx\nconst stream = useStream<EventT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => [...prev, stream.latest!]);\n}, [stream.latest]);\n// Render `items.map(...)` — NOT props.items, NOT stream.all (which accumulates only post-mount).\n```\n\nUse head for newest-first, tail for chat-style. Cap list length if needed. Do not dedupe unless the contract guarantees at-most-once.",
|
|
26
|
+
"## Realtime: append\nStream events are new entities; APPEND to local state. Subscribing alone is not enough — the local list must update for the DOM to re-render.\n\n```tsx\nconst stream = useStream<EventT>('streamName');\nconst [items, setItems] = useState(props.items);\nuseEffect(() => setItems(props.items), [props.items]); // re-seed on ggui_amend repaint — props stay the source of truth\nuseEffect(() => {\n if (!stream.latest) return;\n setItems((prev) => [...prev, stream.latest!]);\n}, [stream.latest]);\n// Render `items.map(...)` — NOT props.items, NOT stream.all (which accumulates only post-mount).\n```\n\nUse head for newest-first, tail for chat-style. Cap list length if needed. Do not dedupe unless the contract guarantees at-most-once.",
|
|
27
27
|
},
|
|
28
28
|
status: {
|
|
29
29
|
axis: "realtime",
|
|
@@ -44,7 +44,7 @@ export const realtimeFragments: Record<string, HarnessFragment> = {
|
|
|
44
44
|
value: "mixed",
|
|
45
45
|
cacheTier: "axisDelta",
|
|
46
46
|
promptText:
|
|
47
|
-
"## Realtime: mixed\nMultiple stream channels with DIFFERENT semantics. Each `streamKinds` entry in the contract maps to one of: merge, append, status, presence. Subscribe + sync to local state PER CHANNEL — subscribing alone never re-renders the DOM.\n\nExample (chat-interface — message=append, typing=presence):\n\n```tsx\nconst messages = useStream<MessageT>('message');\nconst typing = useStream<TypingT>('typing');\nconst [messageList, setMessageList] = useState(props.messages);\nconst [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());\n\nuseEffect(() => {\n if (!messages.latest) return;\n setMessageList((prev) => [...prev, messages.latest!]);\n}, [messages.latest]);\n\nuseEffect(() => {\n if (!typing.latest) return;\n setTypingUsers((prev) => {\n const next = new Set(prev);\n if (typing.latest!.active) next.add(typing.latest!.sender);\n else next.delete(typing.latest!.sender);\n return next;\n });\n}, [typing.latest]);\n```\n\nDo NOT treat all events the same. Do NOT skip the local-state sync — `useStream` alone won't trigger DOM updates of your derived view.",
|
|
47
|
+
"## Realtime: mixed\nMultiple stream channels with DIFFERENT semantics. Each `streamKinds` entry in the contract maps to one of: merge, append, status, presence. Subscribe + sync to local state PER CHANNEL — subscribing alone never re-renders the DOM.\n\nExample (chat-interface — message=append, typing=presence):\n\n```tsx\nconst messages = useStream<MessageT>('message');\nconst typing = useStream<TypingT>('typing');\nconst [messageList, setMessageList] = useState(props.messages);\nconst [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());\n\nuseEffect(() => setMessageList(props.messages), [props.messages]); // re-seed on ggui_amend repaint\n\nuseEffect(() => {\n if (!messages.latest) return;\n setMessageList((prev) => [...prev, messages.latest!]);\n}, [messages.latest]);\n\nuseEffect(() => {\n if (!typing.latest) return;\n setTypingUsers((prev) => {\n const next = new Set(prev);\n if (typing.latest!.active) next.add(typing.latest!.sender);\n else next.delete(typing.latest!.sender);\n return next;\n });\n}, [typing.latest]);\n```\n\nDo NOT treat all events the same. Do NOT skip the local-state sync — `useStream` alone won't trigger DOM updates of your derived view.",
|
|
48
48
|
boilerplateMarker: [
|
|
49
49
|
"",
|
|
50
50
|
" // ── Mixed stream handlers ──",
|
package/src/fragments/state.ts
CHANGED
|
@@ -24,11 +24,11 @@ export const stateFragments: Record<string, HarnessFragment> = {
|
|
|
24
24
|
value: "merge",
|
|
25
25
|
cacheTier: "axisDelta",
|
|
26
26
|
promptText:
|
|
27
|
-
"## State: merge (live entity reconciliation)\n1. Seed `const [items, setItems] = useState(props.items ?? [])
|
|
27
|
+
"## State: merge (live entity reconciliation)\n1. Seed `const [items, setItems] = useState(props.items ?? [])` AND re-seed on prop change: `useEffect(() => setItems(props.items ?? []), [props.items])` — a `ggui_amend` repaint must move the list.\n2. On stream updates: `setItems(prev => prev.map(it => it.id === event.id ? { ...it, ...event } : it))` — merge by id, do NOT append.\n3. Memoize derived views (grouping/sorting/filtering) with useMemo.\n4. Per-item actions pass `{ id, ... }` in the payload.\n5. Never push the stream payload into an append-only list unless realtime=append.",
|
|
28
28
|
boilerplateMarker: [
|
|
29
29
|
"",
|
|
30
30
|
" // ── Live entity state (merge-by-id) ──",
|
|
31
|
-
" // useState(props.items ?? []); merge stream events by item.id; never append.",
|
|
31
|
+
" // useState(props.items ?? []); re-seed via useEffect on [props.items]; merge stream events by item.id; never append.",
|
|
32
32
|
"",
|
|
33
33
|
].join("\n"),
|
|
34
34
|
},
|
|
@@ -217,6 +217,24 @@ function toEvalIssue(issue: RenderCheckIssue): EvalIssue | null {
|
|
|
217
217
|
fix: classifyRenderCrashFix(issue.reason),
|
|
218
218
|
};
|
|
219
219
|
|
|
220
|
+
case "optional-props-omitted":
|
|
221
|
+
// Same crash class as render-no-throw, surfaced by the second
|
|
222
|
+
// render pass with optional props stripped (ggui#528): the wire
|
|
223
|
+
// gate only requires `required: true` props, so a legitimate
|
|
224
|
+
// render may omit these and the component must survive it.
|
|
225
|
+
return {
|
|
226
|
+
tier: 0,
|
|
227
|
+
result,
|
|
228
|
+
category: "crash",
|
|
229
|
+
subcategory,
|
|
230
|
+
severity: "critical",
|
|
231
|
+
description: `Component crashed when optional props were omitted: ${issue.reason}`,
|
|
232
|
+
fix:
|
|
233
|
+
`Guard every optional prop before member access (props.${subject.split(",")[0]?.trim() ?? "x"}?.map(...), ` +
|
|
234
|
+
`a default via destructuring, or an early empty-state return). Optional props (no \`required: true\` in the contract) ` +
|
|
235
|
+
`may be absent at runtime even though the harness fills them.`,
|
|
236
|
+
};
|
|
237
|
+
|
|
220
238
|
case "action-wiring": {
|
|
221
239
|
const fix = issue.outcome === "unverified"
|
|
222
240
|
? `Source shows the action callback flowing into a non-native or custom-component prop. If wiring is intentional (e.g., Dropdown.onChange, drag-drop), this warn is informational — manual/browser verification is required to confirm. Otherwise wire to a native onClick={() => ${subject}(payload)} on <button> or design-system <Button>.`
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
// - declared props appear in the DOM (warn)
|
|
10
10
|
// - declared stream events update the DOM when emitted (warn)
|
|
11
11
|
|
|
12
|
+
import { installProductionActShim } from "./production-act-shim.js";
|
|
13
|
+
|
|
12
14
|
export { runRenderCheck, type RenderCheckResult, type RenderCheckIssue } from "./render-check.js";
|
|
13
15
|
export { createProbe, createProbeWireConfig, type Probe } from "./probe.js";
|
|
14
16
|
export { prepareMockupProps, type MockupPropsResult } from "./prepare-mockup.js";
|
|
@@ -51,6 +53,12 @@ export async function warmupRuntimeRenderProbe(): Promise<{ ms: number; loaded:
|
|
|
51
53
|
missing += 1;
|
|
52
54
|
}
|
|
53
55
|
};
|
|
56
|
+
// Must run before RTL's first import in this process — hosts that
|
|
57
|
+
// call this warmup at process start (server-side blueprint
|
|
58
|
+
// validation) land RTL in the module cache, with its act snapshot
|
|
59
|
+
// taken, here — long before the first real probe call. See
|
|
60
|
+
// production-act-shim.ts's docstring for why.
|
|
61
|
+
installProductionActShim();
|
|
54
62
|
await Promise.all([
|
|
55
63
|
tryLoad("happy-dom"),
|
|
56
64
|
tryLoad("@testing-library/react"),
|
|
@@ -152,6 +152,20 @@ function synthesizeFromSchema(
|
|
|
152
152
|
return synthesizeFromSchema(schema.anyOf[0], hint, warnings, depth + 1);
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
// Draft-07 type array (`['string','null']` — the canonical nullable
|
|
156
|
+
// form since draft-2026-08-19): synthesize from the first non-null
|
|
157
|
+
// member ('null' only when it is the sole member). The depth guard
|
|
158
|
+
// above bounds the recursion.
|
|
159
|
+
if (Array.isArray(schema.type)) {
|
|
160
|
+
const primary = schema.type.find((t) => t !== "null") ?? "null";
|
|
161
|
+
return synthesizeFromSchema(
|
|
162
|
+
{ ...schema, type: primary },
|
|
163
|
+
hint,
|
|
164
|
+
warnings,
|
|
165
|
+
depth + 1,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
switch (schema.type) {
|
|
156
170
|
case "string": {
|
|
157
171
|
// Format-aware placeholder
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// packages/ui-gen/src/harness/check/runtime-render/production-act-shim.ts
|
|
2
|
+
//
|
|
3
|
+
// Minimal adaptation to two upstream constraints, neither of which
|
|
4
|
+
// this package controls:
|
|
5
|
+
//
|
|
6
|
+
// 1. React intentionally strips `act` from its PRODUCTION build.
|
|
7
|
+
// `react/cjs/react.production.js` does not export it — only the
|
|
8
|
+
// development build does (`typeof require('react').act` is
|
|
9
|
+
// `'function'` under a dev build, `undefined` under production).
|
|
10
|
+
// 2. `@testing-library/react`'s `act-compat` module hard-requires
|
|
11
|
+
// `act` at MODULE SCOPE: it snapshots
|
|
12
|
+
// `typeof React.act === 'function' ? React.act : DeprecatedReactTestUtils.act`
|
|
13
|
+
// into a `const` the first time it is imported in the process.
|
|
14
|
+
// Under a production build, that snapshot resolves to React
|
|
15
|
+
// DOM's `react-dom/test-utils` production `act` stub, which
|
|
16
|
+
// THROWS `React.act is not a function` the moment it is called.
|
|
17
|
+
//
|
|
18
|
+
// Hosts that run this probe inside a production Node process
|
|
19
|
+
// (server-side blueprint validation) hit this: every render throws
|
|
20
|
+
// through RTL's `act` wrapper before the probe ever sees the
|
|
21
|
+
// component. Hosts that run it under a development/test build (every
|
|
22
|
+
// CI/vitest run in this repo — `NODE_ENV=test` resolves the dev
|
|
23
|
+
// build) never see it; `act` is real there and this shim is a no-op.
|
|
24
|
+
//
|
|
25
|
+
// **Load-order constraint**: this MUST run before the FIRST import of
|
|
26
|
+
// `@testing-library/react` anywhere in the process — act-compat binds
|
|
27
|
+
// its `act` reference into a module-scope `const` at import time, so
|
|
28
|
+
// patching `react-dom/test-utils` AFTER that import has already
|
|
29
|
+
// happened does nothing (the snapshot already captured the broken
|
|
30
|
+
// stub). Patch `react-dom/test-utils`, never `react` itself — that
|
|
31
|
+
// entry point is deprecated and test-only, so this patch cannot
|
|
32
|
+
// perturb any production rendering path; nothing else in a server
|
|
33
|
+
// process legitimately imports it.
|
|
34
|
+
//
|
|
35
|
+
// `createRequire` (not `import`) reaches these modules deliberately:
|
|
36
|
+
// ESM namespace objects are frozen, so a normal `import` gives no way
|
|
37
|
+
// to mutate `act` on it. The CJS `module.exports` objects `require`
|
|
38
|
+
// resolves are mutable, and RTL's act-compat interop-copies THAT
|
|
39
|
+
// object's properties at its own load time — so mutating it here,
|
|
40
|
+
// before RTL loads, is what makes the patch visible to RTL at all.
|
|
41
|
+
|
|
42
|
+
import { createRequire } from "node:module";
|
|
43
|
+
|
|
44
|
+
let installed = false;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The one property this module reads/mutates on `react` and
|
|
48
|
+
* `react-dom/test-utils`'s CJS exports. A minimal local shape rather
|
|
49
|
+
* than the real (deprecated, possibly absent) type declarations for
|
|
50
|
+
* `react-dom/test-utils` — narrow enough to type-check the mutation
|
|
51
|
+
* without `as any` / `Record<string, unknown>`.
|
|
52
|
+
*/
|
|
53
|
+
interface MutableActCarrier {
|
|
54
|
+
act?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The one React DOM export this module needs — typed narrowly for the same reason. */
|
|
58
|
+
interface FlushSyncCarrier {
|
|
59
|
+
flushSync: (fn: () => void) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Patch `react-dom/test-utils`'s `act` with a `flushSync`-backed
|
|
64
|
+
* replacement, but ONLY when the current process is running React's
|
|
65
|
+
* production build (no real `act` exported). Idempotent — safe to
|
|
66
|
+
* call more than once or from more than one call site; the actual
|
|
67
|
+
* patch only ever applies once per process.
|
|
68
|
+
*
|
|
69
|
+
* No-op (by design, not by accident) whenever `react`'s own `act`
|
|
70
|
+
* export is a real function — every dev/test build, including every
|
|
71
|
+
* vitest run in this repo. This shim changes zero behavior in CI.
|
|
72
|
+
*/
|
|
73
|
+
export function installProductionActShim(): void {
|
|
74
|
+
if (installed) return;
|
|
75
|
+
installed = true;
|
|
76
|
+
|
|
77
|
+
const require_ = createRequire(import.meta.url);
|
|
78
|
+
|
|
79
|
+
let reactCjs: MutableActCarrier;
|
|
80
|
+
try {
|
|
81
|
+
reactCjs = require_("react");
|
|
82
|
+
} catch {
|
|
83
|
+
// React itself isn't resolvable in this process — the probe
|
|
84
|
+
// surfaces its own load error; nothing for this shim to patch.
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (typeof reactCjs.act === "function") {
|
|
89
|
+
// Dev/test builds export a real `act` — strict no-op.
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const testUtils: MutableActCarrier = require_("react-dom/test-utils");
|
|
95
|
+
const reactDom: FlushSyncCarrier = require_("react-dom");
|
|
96
|
+
testUtils.act = createFlushSyncAct(reactDom.flushSync);
|
|
97
|
+
} catch {
|
|
98
|
+
// Frozen/locked exports (or a react-dom build without flushSync)
|
|
99
|
+
// — leave it. The probe then surfaces the raw upstream error,
|
|
100
|
+
// which is the pre-fix behavior: an honest failure, not a
|
|
101
|
+
// silently-broken patch.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build an `act`-compatible replacement backed by `flushSync`, which
|
|
107
|
+
* — unlike `act` — DOES exist in React's production build.
|
|
108
|
+
*
|
|
109
|
+
* `flushSync` is the single property this shim relies on from dev
|
|
110
|
+
* `act`: it forces the scheduled render to commit SYNCHRONOUSLY
|
|
111
|
+
* before returning, so a render throw propagates synchronously to the
|
|
112
|
+
* caller and the DOM is fully committed by the time this resolves.
|
|
113
|
+
*
|
|
114
|
+
* Returned function is act-style: if the callback itself returned a
|
|
115
|
+
* thenable (RTL's async act-wrapped interactions), the result
|
|
116
|
+
* resolves once that thenable settles (rejecting on its rejection);
|
|
117
|
+
* otherwise it resolves after one microtask, mirroring `act`'s own
|
|
118
|
+
* "flush the microtask queue" contract for the synchronous case.
|
|
119
|
+
*
|
|
120
|
+
* **Known, documented delta vs. a real dev `act`**: passive effects
|
|
121
|
+
* (`useEffect`) flush on a LATER task under `flushSync`, not
|
|
122
|
+
* synchronously at act-exit — `flushSync` only guarantees the
|
|
123
|
+
* synchronous commit, not effect flushing. The probe's uncaught-error
|
|
124
|
+
* handlers plus its 100ms teardown grace window (`render-check.ts`'s
|
|
125
|
+
* `cleanupAsyncInfra`) already cover a late effect throwing after this
|
|
126
|
+
* resolves, so this delta does not reopen the crash class the probe
|
|
127
|
+
* exists to catch.
|
|
128
|
+
*
|
|
129
|
+
* Exported separately so the flushSync/thenable semantics are unit
|
|
130
|
+
* testable without a real React runtime.
|
|
131
|
+
*/
|
|
132
|
+
export function createFlushSyncAct(
|
|
133
|
+
flushSync: (fn: () => void) => void,
|
|
134
|
+
): (callback: () => unknown) => Promise<void> {
|
|
135
|
+
return (callback: () => unknown): Promise<void> => {
|
|
136
|
+
let callbackResult: unknown;
|
|
137
|
+
flushSync(() => {
|
|
138
|
+
callbackResult = callback();
|
|
139
|
+
});
|
|
140
|
+
if (isThenable(callbackResult)) {
|
|
141
|
+
const pending = callbackResult;
|
|
142
|
+
return new Promise<void>((resolve, reject) => {
|
|
143
|
+
pending.then(() => resolve(), reject);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return new Promise<void>((resolve) => {
|
|
147
|
+
queueMicrotask(resolve);
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isThenable(value: unknown): value is PromiseLike<unknown> {
|
|
153
|
+
return (
|
|
154
|
+
typeof value === "object" &&
|
|
155
|
+
value !== null &&
|
|
156
|
+
typeof (value as { then?: unknown }).then === "function"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -24,6 +24,7 @@ import { HOOK_NAME_RE, listContractGadgets } from "@ggui-ai/protocol";
|
|
|
24
24
|
import { createProbe, createProbeWireConfig, type Probe } from "./probe.js";
|
|
25
25
|
import { loadComponent } from "./load-component.js";
|
|
26
26
|
import { findWiring, type WiringDetection } from "./find-wiring.js";
|
|
27
|
+
import { installProductionActShim } from "./production-act-shim.js";
|
|
27
28
|
// All happy-dom / RTL / user-event narrowing flows through the typed
|
|
28
29
|
// host boundary (one set of structural interfaces + validating guards;
|
|
29
30
|
// DOM-lib-free — some downstream packages, e.g. cloud/amplify, exclude
|
|
@@ -70,7 +71,8 @@ export interface RenderCheckIssue {
|
|
|
70
71
|
| "render-no-throw"
|
|
71
72
|
| "action-wiring"
|
|
72
73
|
| "prop-coverage"
|
|
73
|
-
| "stream-rerender"
|
|
74
|
+
| "stream-rerender"
|
|
75
|
+
| "optional-props-omitted";
|
|
74
76
|
readonly outcome: CheckOutcome;
|
|
75
77
|
readonly subject?: string;
|
|
76
78
|
readonly reason: string;
|
|
@@ -237,6 +239,9 @@ export async function runRenderCheck(
|
|
|
237
239
|
// internals.
|
|
238
240
|
uninstallSpy = probe.installPostMessageSpy();
|
|
239
241
|
|
|
242
|
+
// Must run before RTL's first import in this process — see
|
|
243
|
+
// production-act-shim.ts's docstring for why.
|
|
244
|
+
installProductionActShim();
|
|
240
245
|
const { render, cleanup } = await import("@testing-library/react");
|
|
241
246
|
// user-event v14 default export — see https://testing-library.com/docs/user-event/intro/
|
|
242
247
|
const userEventModule = await import("@testing-library/user-event");
|
|
@@ -514,6 +519,72 @@ export async function runRenderCheck(
|
|
|
514
519
|
if (issue) issues.push(issue);
|
|
515
520
|
}
|
|
516
521
|
}
|
|
522
|
+
|
|
523
|
+
// ── Check 7: Optional-props omission (BLOCK on crash, ggui#528) ──
|
|
524
|
+
// The main pass renders with EVERY declared prop filled, so a
|
|
525
|
+
// component that assumes an optional prop is present always
|
|
526
|
+
// passes here and crashes only live, when an agent legitimately
|
|
527
|
+
// omits it (the wire gate requires only `required === true`
|
|
528
|
+
// props). Render a second instance with the optional props
|
|
529
|
+
// STRIPPED — a throw is exactly the latent-undefined class this
|
|
530
|
+
// check exists to surface. Runs only when the contract declares
|
|
531
|
+
// at least one optional prop that the mockup filled; zero cost
|
|
532
|
+
// otherwise.
|
|
533
|
+
const declaredProps = input.contract?.propsSpec?.properties;
|
|
534
|
+
if (declaredProps) {
|
|
535
|
+
const optionalFilled = Object.entries(declaredProps)
|
|
536
|
+
.filter(([name, entry]) =>
|
|
537
|
+
(entry as { required?: boolean }).required !== true &&
|
|
538
|
+
input.mockupProps[name] !== undefined,
|
|
539
|
+
)
|
|
540
|
+
.map(([name]) => name);
|
|
541
|
+
if (optionalFilled.length > 0) {
|
|
542
|
+
const requiredOnlyProps: JsonObject = { ...input.mockupProps };
|
|
543
|
+
for (const name of optionalFilled) delete requiredOnlyProps[name];
|
|
544
|
+
const omitBoundaryRef: { error?: Error } = {};
|
|
545
|
+
class OmitErrorBoundary extends React.Component<
|
|
546
|
+
{ children?: React.ReactNode },
|
|
547
|
+
{ caught: boolean }
|
|
548
|
+
> {
|
|
549
|
+
state = { caught: false };
|
|
550
|
+
static getDerivedStateFromError(): { caught: boolean } {
|
|
551
|
+
return { caught: true };
|
|
552
|
+
}
|
|
553
|
+
componentDidCatch(error: Error): void {
|
|
554
|
+
omitBoundaryRef.error ??= error;
|
|
555
|
+
}
|
|
556
|
+
render(): React.ReactNode {
|
|
557
|
+
if (this.state.caught) return null;
|
|
558
|
+
return this.props.children;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
render(
|
|
563
|
+
React.createElement(GguiWireProvider, {
|
|
564
|
+
config: wireConfig,
|
|
565
|
+
children: React.createElement(OmitErrorBoundary, {
|
|
566
|
+
children: React.createElement(Component, requiredOnlyProps),
|
|
567
|
+
}),
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
570
|
+
} catch (e) {
|
|
571
|
+
omitBoundaryRef.error ??=
|
|
572
|
+
e instanceof Error ? e : new Error(String(e));
|
|
573
|
+
}
|
|
574
|
+
if (omitBoundaryRef.error) {
|
|
575
|
+
issues.push({
|
|
576
|
+
check: "optional-props-omitted",
|
|
577
|
+
outcome: "failed",
|
|
578
|
+
subject: optionalFilled.join(", "),
|
|
579
|
+
reason:
|
|
580
|
+
`Render crashed when the optional prop(s) ${optionalFilled
|
|
581
|
+
.map((n) => `'${n}'`)
|
|
582
|
+
.join(", ")} were omitted: ${omitBoundaryRef.error.message}. ` +
|
|
583
|
+
`The contract marks them optional (no \`required: true\`), so a legitimate render may leave them out.`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
517
588
|
} finally {
|
|
518
589
|
cleanup();
|
|
519
590
|
}
|
|
@@ -144,7 +144,10 @@ export async function initSession(input: {
|
|
|
144
144
|
const systemPrompt = input.systemPromptOverride ?? harness.how.systemPrompt;
|
|
145
145
|
|
|
146
146
|
// ── Create + warm-cache the coding agent ──
|
|
147
|
-
|
|
147
|
+
// Pass the full spec (not just `.provider`) so a caller-supplied
|
|
148
|
+
// `routeOverride` (#484) reaches the constructed `LLMAgent` instance
|
|
149
|
+
// and it never falls back to reading `process.env`.
|
|
150
|
+
const codingAgent = createAgent(agents.coding);
|
|
148
151
|
await codingAgent.warmCache(
|
|
149
152
|
agents.coding.model,
|
|
150
153
|
systemPrompt,
|