@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.
Files changed (100) hide show
  1. package/dist/adapters/generation-dispatch.d.ts +18 -1
  2. package/dist/adapters/generation-dispatch.js +819 -1577
  3. package/dist/adapters/generation-dispatch.js.map +1 -1
  4. package/dist/adapters/index.js +28 -11
  5. package/dist/adapters/index.js.map +1 -1
  6. package/dist/advanced/index.d.ts +5 -3
  7. package/dist/advanced/index.js +838 -1579
  8. package/dist/advanced/index.js.map +1 -1
  9. package/dist/blueprint-validator.js +109 -3
  10. package/dist/blueprint-validator.js.map +1 -1
  11. package/dist/boilerplate.js +17 -4
  12. package/dist/boilerplate.js.map +1 -1
  13. package/dist/check/index.d.ts +1 -1
  14. package/dist/check/index.js +11 -1
  15. package/dist/check/index.js.map +1 -1
  16. package/dist/compose.js +5 -5
  17. package/dist/compose.js.map +1 -1
  18. package/dist/{create-ui-generator-GX7tvbyH.d.ts → create-ui-generator-CF0EL0Li.d.ts} +26 -1
  19. package/dist/{dispatch-Deo9qzrG.d.ts → dispatch-D75M-OfZ.d.ts} +1 -1
  20. package/dist/evaluation/axis-checks/index.d.ts +3 -3
  21. package/dist/evaluation/axis-checks/index.js +133 -1
  22. package/dist/evaluation/axis-checks/index.js.map +1 -1
  23. package/dist/evaluation/axis-checks/registry.d.ts +1 -1
  24. package/dist/evaluation/axis-checks/registry.js +133 -1
  25. package/dist/evaluation/axis-checks/registry.js.map +1 -1
  26. package/dist/evaluation/index.d.ts +46 -3
  27. package/dist/evaluation/index.js +410 -24
  28. package/dist/evaluation/index.js.map +1 -1
  29. package/dist/fragments/index.js +5 -5
  30. package/dist/fragments/index.js.map +1 -1
  31. package/dist/harness/check/runtime-render/index.d.ts +2 -2
  32. package/dist/harness/check/runtime-render/index.js +110 -3
  33. package/dist/harness/check/runtime-render/index.js.map +1 -1
  34. package/dist/harness/index.d.ts +5 -126
  35. package/dist/harness/index.js +292 -32
  36. package/dist/harness/index.js.map +1 -1
  37. package/dist/harness/llm-trace-sink.d.ts +1 -1
  38. package/dist/harness/llm-trace-sink.js.map +1 -1
  39. package/dist/harness/prompts.js +17 -3
  40. package/dist/harness/prompts.js.map +1 -1
  41. package/dist/harness/result-types.d.ts +1 -1
  42. package/dist/harness/runtime.d.ts +17 -1
  43. package/dist/harness/runtime.js +23 -8
  44. package/dist/harness/runtime.js.map +1 -1
  45. package/dist/harness/types-public.d.ts +1 -1
  46. package/dist/harness/types-public.js +22 -9
  47. package/dist/harness/types-public.js.map +1 -1
  48. package/dist/harness/validator-trace-sink.d.ts +1 -1
  49. package/dist/index.d.ts +5 -3
  50. package/dist/index.js +838 -1579
  51. package/dist/index.js.map +1 -1
  52. package/dist/llm-router-BEVtbAb9.d.ts +213 -0
  53. package/dist/patch.d.ts +1 -1
  54. package/dist/provider-adapter.d.ts +16 -6
  55. package/dist/provider-adapter.js.map +1 -1
  56. package/dist/providers/index.d.ts +54 -99
  57. package/dist/providers/index.js +13 -8
  58. package/dist/providers/index.js.map +1 -1
  59. package/dist/{render-check-CNZjHD5g.d.ts → render-check-BNYGVDJd.d.ts} +1 -1
  60. package/dist/{types-public-C36k1q6s.d.ts → types-public-DIpjC-OA.d.ts} +1 -1
  61. package/dist/validation/index.js +2 -2
  62. package/dist/validation/index.js.map +1 -1
  63. package/dist/workflows.d.ts +1 -1
  64. package/package.json +18 -18
  65. package/src/adapters/generation-dispatch.ts +28 -2
  66. package/src/adapters/openrouter/client.ts +2 -2
  67. package/src/adapters/openrouter/types.ts +10 -0
  68. package/src/boilerplate/generate.ts +10 -2
  69. package/src/boilerplate/json-schema-ts.ts +22 -2
  70. package/src/boilerplate/system-prompt.ts +4 -2
  71. package/src/compile.ts +1 -1
  72. package/src/contract-context.ts +10 -2
  73. package/src/create-ui-generator.ts +58 -11
  74. package/src/design-system-docs.ts +6 -4
  75. package/src/evaluation/axis-checks/checks/state-merge.ts +1 -1
  76. package/src/evaluation/axis-checks/checks/state-ui-affordance.ts +162 -0
  77. package/src/evaluation/axis-checks/checks/universal.ts +39 -0
  78. package/src/evaluation/axis-checks/registry.ts +2 -0
  79. package/src/evaluation/index.ts +11 -0
  80. package/src/evaluation/llm-evaluator.ts +26 -3
  81. package/src/evaluation/visual-evaluator.ts +49 -66
  82. package/src/fragments/realtime.ts +3 -3
  83. package/src/fragments/state.ts +2 -2
  84. package/src/harness/check/runtime-render/adapter.ts +18 -0
  85. package/src/harness/check/runtime-render/index.ts +8 -0
  86. package/src/harness/check/runtime-render/prepare-mockup.ts +14 -0
  87. package/src/harness/check/runtime-render/production-act-shim.ts +158 -0
  88. package/src/harness/check/runtime-render/render-check.ts +72 -1
  89. package/src/harness/coding/init-session.ts +4 -1
  90. package/src/harness/coding/run-eval-round.ts +67 -5
  91. package/src/harness/index.ts +1 -1
  92. package/src/harness/llm-router.ts +451 -27
  93. package/src/harness/llm-trace-sink.ts +2 -1
  94. package/src/harness/runtime.ts +15 -0
  95. package/src/index.ts +5 -5
  96. package/src/provider-adapter.ts +16 -6
  97. package/src/providers/bedrock.ts +89 -26
  98. package/src/providers/index.ts +16 -4
  99. package/src/tools/render-check.ts +19 -7
  100. 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,
@@ -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
- const agent = createAgent(routerProvider);
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
- await page.setContent(html, { waitUntil: 'networkidle0', timeout: 15000 });
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
- interface VisualProviderResponse {
247
- text: string;
248
- inputTokens: number;
249
- outputTokens: number;
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
- provider: 'claude' | 'google',
276
+ config: VisualEvalConfig,
254
277
  model: string,
255
278
  prompt: string,
256
279
  screenshot: Buffer,
257
280
  originalPrompt: string,
258
- ): Promise<VisualProviderResponse> {
281
+ ) {
259
282
  const userPrompt = `## Original Request\n${originalPrompt}\n\nEvaluate the screenshot of the generated component.`;
260
-
261
- if (provider === 'claude') {
262
- const { default: Anthropic } = await import('@anthropic-ai/sdk');
263
- const client = new Anthropic({
264
- apiKey: process.env.ANTHROPIC_API_KEY,
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
- const usage = response.usageMetadata;
310
- return {
311
- text: response.candidates?.[0]?.content?.parts?.map(p => p.text || '').join('') || '',
312
- inputTokens: usage?.promptTokenCount ?? 0,
313
- outputTokens: usage?.candidatesTokenCount ?? 0,
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.provider,
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 ──",
@@ -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 ?? [])`.\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.",
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
- const codingAgent = createAgent(agents.coding.provider);
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,