@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
@@ -1,7 +1,7 @@
1
1
  import { C as Classification } from './axes-CzLEMDeB.js';
2
2
  import { Workflow } from './harness/types-public.js';
3
3
  import '@ggui-ai/protocol';
4
- import './types-public-C36k1q6s.js';
4
+ import './types-public-DIpjC-OA.js';
5
5
  import './types-BOvHNG7K.js';
6
6
  import './llm.js';
7
7
  import './policy.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-gen",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "description": "UI generation harness for ggui — the LLM system prompt, multi-turn coding loop, and evaluation engine that turn a DataContract into a React component. Implements the UiGenerator contract from @ggui-ai/mcp-server-core; consumed by @ggui-ai/mcp-server.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -185,12 +185,12 @@
185
185
  "peerDependencies": {
186
186
  "@google/adk": "^0.5.0",
187
187
  "@openai/agents": "^0.7.1",
188
- "puppeteer-core": "^24.40.0",
188
+ "puppeteer-core": "^24.40.0 || ^25.0.0",
189
189
  "react": "^18.0.0 || ^19.0.0",
190
190
  "react-dom": "^18.0.0 || ^19.0.0",
191
- "@ggui-ai/mcp-server-core": "0.8.0",
192
- "@ggui-ai/project-config": "0.8.0",
193
- "@ggui-ai/protocol": "0.8.0"
191
+ "@ggui-ai/mcp-server-core": "0.10.0",
192
+ "@ggui-ai/project-config": "0.10.0",
193
+ "@ggui-ai/protocol": "0.10.0"
194
194
  },
195
195
  "peerDependenciesMeta": {
196
196
  "@google/adk": {
@@ -210,9 +210,9 @@
210
210
  }
211
211
  },
212
212
  "dependencies": {
213
- "@anthropic-ai/bedrock-sdk": "^0.29.0",
214
- "@anthropic-ai/claude-agent-sdk": "0.2.76",
215
- "@anthropic-ai/sdk": "^0.91.1",
213
+ "@anthropic-ai/bedrock-sdk": "^0.32.1",
214
+ "@anthropic-ai/claude-agent-sdk": "0.3.229",
215
+ "@anthropic-ai/sdk": "^0.116.0",
216
216
  "@google/genai": "^2.8.0",
217
217
  "@testing-library/dom": "^10.4.0",
218
218
  "@testing-library/react": "^16.0.0",
@@ -229,28 +229,28 @@
229
229
  "openai": "^6.29.0",
230
230
  "typescript": "^5.0.0",
231
231
  "zod": "^4.3.6",
232
- "@ggui-ai/design": "0.8.0",
233
- "@ggui-ai/gadgets": "0.8.0",
234
- "@ggui-ai/ui-visual-tester": "0.8.0",
235
- "@ggui-ai/wire": "0.8.0",
236
- "@ggui-ai/sandbox": "0.8.0"
232
+ "@ggui-ai/gadgets": "0.10.0",
233
+ "@ggui-ai/design": "0.10.0",
234
+ "@ggui-ai/sandbox": "0.10.0",
235
+ "@ggui-ai/ui-visual-tester": "0.10.0",
236
+ "@ggui-ai/wire": "0.10.0"
237
237
  },
238
238
  "devDependencies": {
239
239
  "@google/adk": "^0.5.0",
240
240
  "@openai/agents": "^0.7.1",
241
- "@sparticuz/chromium": "^143.0.4",
241
+ "@sparticuz/chromium": "^149.0.0",
242
242
  "@types/eslint": "^9.6.1",
243
243
  "@types/node": "^22.0.0",
244
244
  "@types/react": "^19.0.0",
245
245
  "@types/react-dom": "^19.0.0",
246
- "puppeteer-core": "^24.40.0",
246
+ "puppeteer-core": "^25.6.0",
247
247
  "react": "^19.0.0",
248
248
  "react-dom": "^19.0.0",
249
249
  "tsup": "^8.5.0",
250
250
  "vitest": "^3.2.6",
251
- "@ggui-ai/project-config": "0.8.0",
252
- "@ggui-ai/protocol": "0.8.0",
253
- "@ggui-ai/mcp-server-core": "0.8.0"
251
+ "@ggui-ai/mcp-server-core": "0.10.0",
252
+ "@ggui-ai/project-config": "0.10.0",
253
+ "@ggui-ai/protocol": "0.10.0"
254
254
  },
255
255
  "engines": {
256
256
  "node": ">=20.0.0"
@@ -53,6 +53,7 @@ import type {
53
53
  } from "../harness/result-types.js";
54
54
  import type { AdapterResult, ProviderName, ToolDefinition } from "./types.js";
55
55
  import type { QualityConfig } from "../evaluation/types-public.js";
56
+ import type { AgentConfig } from "../harness/llm-router.js";
56
57
  import {
57
58
  type GadgetDescriptor,
58
59
  type JsonObject,
@@ -147,6 +148,21 @@ export interface GenerationDispatchParams {
147
148
  * STDLIB-only / bench / direct callers.
148
149
  */
149
150
  gadgetTypes?: Readonly<Record<string, string>>;
151
+ /**
152
+ * Explicit per-call credentials/routing (#484) — see
153
+ * `AgentConfig.routeOverride`. Threaded onto the coding + evaluation
154
+ * agent specs so `resolveSessionAgents` → `createAgent` never falls
155
+ * back to reading `process.env`. Absent (default) preserves
156
+ * existing behavior for callers that don't set it.
157
+ */
158
+ routeOverride?: AgentConfig["routeOverride"];
159
+ /**
160
+ * Retry observer (#489) — see `AgentConfig.onRetry`. Threaded onto
161
+ * the coding + evaluation agent specs so a rate-limited retry inside
162
+ * either agent's `apiCall()` reaches the caller. Absent (default) is
163
+ * a no-op.
164
+ */
165
+ onRetry?: AgentConfig["onRetry"];
150
166
  }
151
167
 
152
168
  /**
@@ -186,9 +202,19 @@ export async function dispatchGeneration(
186
202
 
187
203
  const routerProvider = mapProviderForLLMRouter(params.provider);
188
204
  const resolvedModel = params.models?.coding ?? params.models?.default ?? params.model;
189
- const codingAgent = { provider: routerProvider, model: resolvedModel };
205
+ const codingAgent = {
206
+ provider: routerProvider,
207
+ model: resolvedModel,
208
+ routeOverride: params.routeOverride,
209
+ onRetry: params.onRetry,
210
+ };
190
211
  const evaluationAgent = params.models?.evaluation
191
- ? { provider: routerProvider, model: params.models.evaluation }
212
+ ? {
213
+ provider: routerProvider,
214
+ model: params.models.evaluation,
215
+ routeOverride: params.routeOverride,
216
+ onRetry: params.onRetry,
217
+ }
192
218
  : undefined;
193
219
  // ModelRoles doesn't currently expose a visualEval slot — falls through
194
220
  // to the documented evaluation → coding chain inside resolveSessionAgents.
@@ -49,7 +49,7 @@ export class OpenRouterClient {
49
49
  if (!response.ok) {
50
50
  const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
51
51
  const message = (errorBody as { error?: { message?: string } }).error?.message ?? response.statusText;
52
- throw new OpenRouterError(message, response.status);
52
+ throw new OpenRouterError(message, response.status, undefined, response.headers);
53
53
  }
54
54
 
55
55
  return response.json() as Promise<OpenRouterChatResponse>;
@@ -72,7 +72,7 @@ export class OpenRouterClient {
72
72
  if (!response.ok) {
73
73
  const errorBody = await response.json().catch(() => ({ error: { message: response.statusText } }));
74
74
  const message = (errorBody as { error?: { message?: string } }).error?.message ?? response.statusText;
75
- throw new OpenRouterError(message, response.status);
75
+ throw new OpenRouterError(message, response.status, undefined, response.headers);
76
76
  }
77
77
 
78
78
  if (!response.body) {
@@ -127,6 +127,16 @@ export class OpenRouterError extends Error {
127
127
  message: string,
128
128
  public readonly status: number,
129
129
  public readonly code?: string,
130
+ /**
131
+ * Response headers, when the caller had access to them at throw
132
+ * time (#489). Only `client.ts`'s two HTTP-response-driven throw
133
+ * sites populate this — the "no response body" throw has no
134
+ * response to read headers from. Duck-typed against the same
135
+ * `{ get(name): string | null }` shape `llm-router.ts`'s
136
+ * `extractRetryAfterSec` already reads from Anthropic/OpenAI SDK
137
+ * errors, so OpenRouter gets retry-after support for free.
138
+ */
139
+ public readonly headers?: { get(name: string): string | null },
130
140
  ) {
131
141
  super(message);
132
142
  this.name = 'OpenRouterError';
@@ -191,14 +191,22 @@ export function generateBoilerplate(
191
191
  const propsFields: string[] = [];
192
192
  const propsProperties = contract?.propsSpec?.properties ?? {};
193
193
  for (const [key, entry] of Object.entries(propsProperties)) {
194
- const required = entry.required !== false;
194
+ // Required is OPT-IN (`required === true`) — the same reading the
195
+ // wire gate applies (`contract-validator.ts` builds the JSON-Schema
196
+ // `required` list from `=== true`), the contract summary's `!`
197
+ // marker uses, and coverage annotates with. The old `!== false`
198
+ // reading typed omitted-`required` props as always-present, so a
199
+ // render the gate legitimately accepts (agent omits an optional
200
+ // prop) crashed on `props.x.map(...)` — ggui#528.
201
+ const required = entry.required === true;
195
202
  const nullable = entry.schema.nullable === true;
196
203
  const tsType = jsonSchemaTypeToTs(entry.schema);
197
204
  const fullType = nullable ? `${tsType} | null` : tsType;
198
- // Build comment: description + default value hint
205
+ // Build comment: description + default hint + optionality contract
199
206
  const parts: string[] = [];
200
207
  if (entry.description) parts.push(entry.description);
201
208
  if (entry.default !== undefined) parts.push(`(default: ${JSON.stringify(entry.default)})`);
209
+ if (!required) parts.push("(optional — the agent may omit it; guard or fall back)");
202
210
  const desc = parts.length > 0 ? ` // ${parts.join(' ')}` : "";
203
211
  propsFields.push(` ${key}${required ? "" : "?"}: ${fullType};${desc}`);
204
212
  }
@@ -35,6 +35,21 @@ export function jsonSchemaTypeToTs(schema: JsonSchema): string {
35
35
  .join(' | ');
36
36
  }
37
37
 
38
+ // ── Draft-07 type array (`['string','null']` — the canonical
39
+ // nullable form since draft-2026-08-19): members' TS union. Each
40
+ // member re-enters with the single type so object/array members
41
+ // keep their properties/items; `nullable` is folded in here (not in
42
+ // the recursion) so it can't double-append. ──
43
+ if (Array.isArray(schema.type)) {
44
+ const { nullable: _nullable, ...rest } = schema;
45
+ const members = [...new Set(schema.type)].map((member) =>
46
+ jsonSchemaTypeToTs({ ...rest, type: member }),
47
+ );
48
+ const unique = [...new Set(members)];
49
+ if (schema.nullable && !unique.includes('null')) unique.push('null');
50
+ return unique.join(' | ');
51
+ }
52
+
38
53
  let result: string;
39
54
 
40
55
  switch (schema.type) {
@@ -54,8 +69,13 @@ export function jsonSchemaTypeToTs(schema: JsonSchema): string {
54
69
  case 'array': {
55
70
  if (schema.items) {
56
71
  const itemType = jsonSchemaTypeToTs(schema.items);
57
- // Use Array<T> for complex object types, T[] for simple
58
- result = (schema.items.type === 'object' && schema.items.properties)
72
+ // Use Array<T> for complex object types, T[] for simple.
73
+ // `items.type` may be a type SET — treat any set containing
74
+ // 'object' like the single-string form.
75
+ const itemsDeclareObject = Array.isArray(schema.items.type)
76
+ ? schema.items.type.includes('object')
77
+ : schema.items.type === 'object';
78
+ result = (itemsDeclareObject && schema.items.properties)
59
79
  ? `Array<${itemType}>`
60
80
  : (itemType.includes('|') ? `(${itemType})[]` : `${itemType}[]`);
61
81
  } else {
@@ -347,13 +347,15 @@ A \`DataContract\` declares everything a GguiSession exchanges with the outside
347
347
 
348
348
  ## Defensive coding for absent / late-arriving data
349
349
 
350
- Props arrive via \`ggui_update\` and may be partial on first render. Stream channels start empty and fill over time. Context slots start at their declared default (often \`null\`). **Never assume a field exists before you read it.**
350
+ Props arrive at mount and may be partial on first render; later prop changes reach the SAME mounted component via \`ggui_amend\` (in-place repaint) on the live channel. Stream channels start empty and fill over time. Context slots start at their declared default (often \`null\`). **Never assume a field exists before you read it.**
351
351
 
352
+ - **Optional contract props**: a \`Props\` field marked \`?\` in the boilerplate may be omitted by the agent ENTIRELY — the wire gate only enforces \`required: true\` entries, and the harness verifies your component with those props stripped. Guard them (\`?? default\`, \`?.\`, or an empty-state branch) from the FIRST render.
352
353
  - **Array iteration**: always default to \`[]\` before \`.map\`/\`.filter\`/\`.length\`. Use \`(props.items ?? []).map(...)\` not \`props.items.map(...)\`. Same for stream.all, stream.latest, etc.
353
354
  - **Object access**: optional-chain through nested fields. \`props.user?.name ?? 'Anonymous'\` not \`props.user.name\`.
354
355
  - **Number ops**: default before arithmetic. \`(props.count ?? 0) + 1\` not \`props.count + 1\`.
355
356
  - **Stream latest**: \`useStream\` returns \`{latest: T | null, all: T[], isComplete: boolean}\`. The default \`all\` is \`[]\` so it's safe to map; \`latest\` is null until the first frame arrives — guard before reading \`.foo\`.
356
357
  - **Stream reconciliation**: when a stream event carries an \`action\` discriminant (e.g. \`create | move | edit | delete\`), the channel is a CRUD feed — your handler MUST branch on EVERY value: append on \`create\`, drop on \`delete\`, replace-by-id on \`move\` / \`edit\`. Merging only the "edit" case silently loses created and deleted items. Reconcile into the SAME state that seeds from \`props\` (e.g. \`useState(() => props.tasks ?? [])\`) so the seed data and the live feed render as one list — and handle an event whose id is not yet present (a \`create\` for an unknown item) by inserting it, not ignoring it.
358
+ - **Props re-sync on repaint**: props the agent owns are the source of truth for display — prefer rendering DIRECTLY from \`props\` (controlled, no local copy). When local optimistic state genuinely needs a prop seed (a click bumps a count before the agent confirms), it MUST re-sync on prop change — \`useEffect(() => setCount(props.count ?? 0), [props.count])\` — or a \`ggui_amend\` repaint applies the new props while the display keeps the stale local value. BAD: \`const [count, setCount] = useState(props.count ?? 0)\` alone. GOOD: the same seed + the one-line re-sync effect.
357
359
  - **Loading state**: while data is still absent, render \`<Skeleton>\` placeholders — never a blank screen. \`<Skeleton variant="text" />\` for a text line, \`variant="circle"\` for an avatar slot, default \`rect\` for a block.
358
360
  - **Empty state**: when a list or results array is empty, render \`<EmptyState title="…" description="…" />\` — a region that renders nothing when empty looks broken to the user.
359
361
 
@@ -481,7 +483,7 @@ Every value change is mirrored to the host LLM's context automatically (debounce
481
483
 
482
484
  **When NOT to use it.** Local UI state the contract did NOT declare — \`isDropdownOpen\`, hover flags, animation phase, ephemeral toggles. For those, use a plain \`useState\` directly. The runtime ignores undeclared state.
483
485
 
484
- **\`contextSpec\` direction is one-way: client → agent.** The agent uses \`propsSpec\` (via \`ggui_update\`) and \`streamSpec\` (via the live channel) to push state TO the client. Don't try to write to the agent via \`contextSpec\` — there is no return path.
486
+ **\`contextSpec\` direction is one-way: client → agent.** The agent uses \`propsSpec\` (via \`ggui_amend\` for in-place repaints of this mount, or \`ggui_update\` when it mints a new history card) and \`streamSpec\` (via the live channel) to push state TO the client. Don't try to write to the agent via \`contextSpec\` — there is no return path.
485
487
 
486
488
  **Schema mismatches drop silently.** If you set a value that doesn't match the slot's schema (e.g. a string into a \`{type: 'number'}\` slot), the runtime logs a dev \`console.warn\` and skips the post. Make sure your setter calls produce values that match the declared shape.
487
489
 
package/src/compile.ts CHANGED
@@ -153,7 +153,7 @@ export async function compileComponentCode(source: string): Promise<string> {
153
153
  * `response.sourceCode` so downstream consumers that want human-readable
154
154
  * source (benchmarks, blueprint cache seeding) still have it.
155
155
  *
156
- * const raw = createUiGenerator({ adapter });
156
+ * const raw = createUiGenerator();
157
157
  * const oss = withBrowserCompile(raw);
158
158
  * const out = await oss.generate({ ... });
159
159
  * // out.response.componentCode → ESM (browser-ready)
@@ -257,11 +257,19 @@ export function buildContractsContext(
257
257
  const uiRequirements: string[] = [];
258
258
  if (contract.propsSpec?.properties) {
259
259
  for (const [name, entry] of Object.entries(contract.propsSpec.properties)) {
260
- const required = (entry as { required?: boolean }).required;
261
- if (required !== false) {
260
+ // Opt-in required (`=== true`) — the wire gate's reading, ggui#528.
261
+ // An omitted-`required` prop is OPTIONAL: the agent may leave it
262
+ // out of a legitimate render, so the derived instruction must
263
+ // demand a presence-safe component, not unconditional display.
264
+ const required = (entry as { required?: boolean }).required === true;
265
+ if (required) {
262
266
  uiRequirements.push(
263
267
  `- **Required prop \`${name}\`** must appear somewhere in rendered DOM (display the value, or use it to drive a label/aria/key).`,
264
268
  );
269
+ } else {
270
+ uiRequirements.push(
271
+ `- **Optional prop \`${name}\`** may be ABSENT at runtime — the component must render without it (guard the access or apply its default) and must display it when it IS provided.`,
272
+ );
265
273
  }
266
274
  }
267
275
  }
@@ -61,6 +61,7 @@ import {
61
61
  import type { RenderingContext } from './contract-context.js';
62
62
  import { resolveRoute, applyRouteToEnv } from './adapters/provider-router.js';
63
63
  import type { QualityConfig } from './evaluation/types-public.js';
64
+ import type { AgentConfig } from './harness/llm-router.js';
64
65
 
65
66
  /** The slug for the OSS default seed generator. */
66
67
  const DEFAULT_TIER: GeneratorTier = 'default';
@@ -138,6 +139,30 @@ export interface CreateUiGeneratorOptions {
138
139
  * `appGadgets` themselves; the legacy handler path stays unchanged.
139
140
  */
140
141
  readonly gadgetCatalog?: GadgetCatalogAdapter;
142
+ /**
143
+ * When `true`, `generate()` never mutates `process.env` — the
144
+ * resolved provider route is threaded structurally into the
145
+ * dispatch call instead of being written to and restored from the
146
+ * process environment. Use this when running concurrent
147
+ * generations in one process (the default backup/mutate/restore
148
+ * dance is NOT concurrency-safe: two simultaneous calls race on
149
+ * the shared `process.env`).
150
+ *
151
+ * Default `false` — preserves the original env-mutation behavior
152
+ * for callers that don't need concurrent generations in-process.
153
+ */
154
+ readonly disableEnvMutation?: boolean;
155
+ /**
156
+ * Retry observer (#489) — see `AgentConfig.onRetry`. Bound ONCE at
157
+ * factory construction time (mirrors `disableEnvMutation`), not
158
+ * per-call: `generate()` is invoked once per request, but the
159
+ * generator instance itself is typically long-lived and cached by
160
+ * the caller, so a single observer covers every generation it runs.
161
+ * Fires whenever any agent's `apiCall()` retries a rate-limited
162
+ * request. Absent (default) is a no-op — retries still happen and
163
+ * still log via `console.warn`, just without a caller-side hook.
164
+ */
165
+ readonly onRetry?: AgentConfig['onRetry'];
141
166
  }
142
167
 
143
168
  export function createUiGenerator(
@@ -149,6 +174,8 @@ export function createUiGenerator(
149
174
  maxEvalRounds,
150
175
  qualityConfig,
151
176
  gadgetCatalog,
177
+ disableEnvMutation = false,
178
+ onRetry,
152
179
  } = options;
153
180
 
154
181
  const identity = resolveIdentity(options);
@@ -193,19 +220,37 @@ export function createUiGenerator(
193
220
  }
194
221
 
195
222
  const envBackup: Record<string, string | undefined> = {};
196
- for (const k of Object.keys(route.env)) envBackup[k] = process.env[k];
197
- const baseEnvForApply: Record<string, string> = {};
198
- for (const [k, v] of Object.entries(process.env)) {
199
- if (v !== undefined) baseEnvForApply[k] = v;
200
- }
201
- const routedEnv = applyRouteToEnv(baseEnvForApply, route);
202
- for (const [k, v] of Object.entries(routedEnv)) process.env[k] = v;
203
- // Also delete any keys the route asked to clear (route.env values
204
- // marked undefined).
205
- for (const [k, v] of Object.entries(route.env)) {
206
- if (v === undefined) delete process.env[k];
223
+ if (!disableEnvMutation) {
224
+ for (const k of Object.keys(route.env)) envBackup[k] = process.env[k];
225
+ const baseEnvForApply: Record<string, string> = {};
226
+ for (const [k, v] of Object.entries(process.env)) {
227
+ if (v !== undefined) baseEnvForApply[k] = v;
228
+ }
229
+ const routedEnv = applyRouteToEnv(baseEnvForApply, route);
230
+ for (const [k, v] of Object.entries(routedEnv)) process.env[k] = v;
231
+ // Also delete any keys the route asked to clear (route.env values
232
+ // marked undefined).
233
+ for (const [k, v] of Object.entries(route.env)) {
234
+ if (v === undefined) delete process.env[k];
235
+ }
207
236
  }
208
237
 
238
+ // #484 — when `disableEnvMutation` is set, skip the process-env
239
+ // mutation above entirely and thread the resolved route
240
+ // structurally into the dispatch call instead. `route.env`
241
+ // already carries exactly the key(s) that would have gone into
242
+ // `process.env`, so no extra resolution work is needed here.
243
+ const routeOverride = disableEnvMutation
244
+ ? {
245
+ apiKey:
246
+ route.env['ANTHROPIC_API_KEY'] ??
247
+ route.env['OPENAI_API_KEY'] ??
248
+ route.env['GEMINI_API_KEY'] ??
249
+ route.env['OPENROUTER_API_KEY'],
250
+ useBedrock: route.env['CLAUDE_CODE_USE_BEDROCK'] === '1',
251
+ }
252
+ : undefined;
253
+
209
254
  try {
210
255
  const tools = createGeneratorTools({
211
256
  ...(input.contract ? { contract: input.contract } : {}),
@@ -269,6 +314,8 @@ export function createUiGenerator(
269
314
  ? { gadgetTypes: input.gadgetTypes }
270
315
  : {}),
271
316
  enableRuntimeRender,
317
+ ...(routeOverride !== undefined ? { routeOverride } : {}),
318
+ ...(onRetry !== undefined ? { onRetry } : {}),
272
319
  });
273
320
 
274
321
  const metadata: GenerationMetadata = {
@@ -54,10 +54,12 @@ These tokens adapt automatically to any theme (light, dark, branded). **ALWAYS u
54
54
  - Borders/dividers → \`var(--ggui-color-outline)\` or \`var(--ggui-color-outlineVariant)\`
55
55
 
56
56
  ### State Colors
57
- - var(--ggui-color-success) — success states
58
- - var(--ggui-color-warning) — warning states
59
- - var(--ggui-color-error) — error states, destructive actions
60
- - var(--ggui-color-info) — informational
57
+ Semantic families ship as SCALES; the \`500\` stop is the role color (per-mode brightness), \`50\` the tint, \`800\` the on-tint text:
58
+ - var(--ggui-color-success-500) — success states (tint bg: success-50, text on tint: success-800)
59
+ - var(--ggui-color-warning-500) — warning states (warning-50 / warning-800)
60
+ - var(--ggui-color-error-500) — error states, destructive actions (error-50 / error-800)
61
+ - var(--ggui-color-info-500) — informational (info-50 / info-800)
62
+ Prefer the primitives' \`tone="success|warning|error|info"\` prop over hand-written vars — it resolves to the same stops.
61
63
 
62
64
  ### IMPORTANT: Color Rules
63
65
  - **NEVER** use hardcoded hex colors. ONLY use var(--ggui-color-*) tokens.
@@ -29,7 +29,7 @@ function runStateSeededFromProps(input: AxisCheckInput): EvalIssue[] {
29
29
  mkIssue(
30
30
  "state.merge.seeded_from_props",
31
31
  `Entity collection "${e.name}" is not seeded from props — no useState initializer reads props.${e.name}.`,
32
- `Add \`const [${e.name}, set${cap(e.name)}] = useState(props.${e.name});\` so stream/action updates can merge into live state.`,
32
+ `Add \`const [${e.name}, set${cap(e.name)}] = useState(props.${e.name});\` paired with \`useEffect(() => set${cap(e.name)}(props.${e.name}), [props.${e.name}]);\` so stream/action updates merge into live state AND agent repaints (ggui_amend) re-seed it (#480).`,
33
33
  ),
34
34
  );
35
35
  }
@@ -0,0 +1,162 @@
1
+ // packages/ui-gen/src/evaluation/axis-checks/checks/state-ui-affordance.ts
2
+ //
3
+ // `state.ui_affordance.state_aria_present` (ggui#408) — a hand-rolled
4
+ // stateful control must expose its state in ARIA.
5
+ //
6
+ // Why this is a DETERMINISTIC check and not (only) an LLM criterion:
7
+ // the Experiment-53 baseline showed the defect class is structurally
8
+ // invisible to the LLM leg — `state=ui-affordance` with no writes
9
+ // classifies `riskTier=low`, and the low-risk bypass skips tier-1+2
10
+ // entirely, so 10/10 ARIA-less generations were never evaluated at
11
+ // all. Axis checks run BEFORE the bypass decision, and a `fail` among
12
+ // them disables the bypass for that round — which both surfaces the
13
+ // finding and routes it into the coding loop. Prototype validation
14
+ // against the 27-cell baseline corpus: precision 1.00, recall 0.80
15
+ // (both false negatives closed here: lowercase tags — `<div
16
+ // as={Clickable}>` is the WORSE variant since `as` is inert on a
17
+ // native element — and callback-prop indirection in extracted
18
+ // subcomponents).
19
+ //
20
+ // Detection predicate (v2 — "the element's own onClick mutates the
21
+ // affordance state"; v1's styling-dataflow precondition scored recall
22
+ // 0.30, do not resurrect it): an element fires when its `onClick`
23
+ // references a `useState` setter, a local handler that calls one, or
24
+ // an `on[A-Z]…` callback prop (extracted-subcomponent shape) — AND it
25
+ // is not a semantics-owning primitive, carries no state ARIA
26
+ // (`aria-checked/pressed/selected/expanded/current` or any `role=`),
27
+ // and contains no state-carrying primitive in its subtree (pushing
28
+ // duplicate ARIA onto a wrapper around `<Checkbox>` is itself a
29
+ // defect the judge fails — never demand it).
30
+ //
31
+ // Gate: `{axis: "state", values: ["ui-affordance"]}` ONLY. The
32
+ // predicate must not reach `state=payload/draft` (form-field wrappers
33
+ // with onClick would false-flag) or `state=merge` (kanban's per-item
34
+ // onClick is a MOVE, not a boolean affordance).
35
+
36
+ import type { EvalIssue } from "../../types-public.js";
37
+ import type { AxisCheck, AxisCheckInput } from "../types.js";
38
+ import { mkIssue } from "../helpers.js";
39
+
40
+ const STATE_ARIA_OR_ROLE = /aria-(checked|pressed|selected|expanded|current)\s*=|role\s*=/;
41
+ const STATE_PRIMITIVE_IN_SUBTREE = /<(Checkbox|Toggle|Tabs|Accordion)[\s/>]/;
42
+ /** Primitives that own their own interaction semantics — never flagged. */
43
+ const SEMANTIC_PRIMITIVES = new Set(["Button", "Checkbox", "Toggle", "Link", "MenuItem"]);
44
+
45
+ /** Collect `const [x, setX] = useState(...)` setter names. */
46
+ function collectSetters(src: string): Set<string> {
47
+ const setters = new Set<string>();
48
+ for (const m of src.matchAll(/const\s*\[\s*\w+\s*,\s*(set\w+)\s*\]\s*=\s*useState/g)) {
49
+ setters.add(m[1]);
50
+ }
51
+ return setters;
52
+ }
53
+
54
+ /** Collect local handler names whose body (nearby) calls a setter. */
55
+ function collectHandlers(src: string, setters: Set<string>): Set<string> {
56
+ const handlers = new Set<string>();
57
+ for (const m of src.matchAll(/const\s+(\w+)\s*=\s*(?:\([^)]*\)|\w+)\s*=>\s*\{?/g)) {
58
+ const body = src.slice(m.index + m[0].length, m.index + m[0].length + 400);
59
+ for (const s of setters) {
60
+ if (body.includes(s)) {
61
+ handlers.add(m[1]);
62
+ break;
63
+ }
64
+ }
65
+ }
66
+ return handlers;
67
+ }
68
+
69
+ /** The attribute block of an opening tag: from after the tag name to its `>` (brace-aware). */
70
+ function attrBlock(src: string, start: number): { block: string; end: number } {
71
+ let depth = 0;
72
+ for (let i = start; i < src.length; i++) {
73
+ const c = src[i];
74
+ if (c === "{") depth++;
75
+ else if (c === "}") depth--;
76
+ else if (c === ">" && depth === 0) return { block: src.slice(start, i), end: i };
77
+ }
78
+ return { block: src.slice(start, start + 900), end: start + 900 };
79
+ }
80
+
81
+ /** The brace-balanced expression of `onClick={…}`, or null when absent. */
82
+ function onClickValue(block: string): string | null {
83
+ const m = block.match(/onClick\s*=\s*\{/);
84
+ if (!m || m.index === undefined) return null;
85
+ const open = m.index + m[0].length - 1;
86
+ let depth = 0;
87
+ for (let i = open; i < block.length; i++) {
88
+ if (block[i] === "{") depth++;
89
+ else if (block[i] === "}") {
90
+ depth--;
91
+ if (depth === 0) return block.slice(open + 1, i);
92
+ }
93
+ }
94
+ return block.slice(open + 1);
95
+ }
96
+
97
+ function subtree(src: string, pos: number, tag: string): string {
98
+ const close = src.indexOf(`</${tag}>`, pos);
99
+ return src.slice(pos, close === -1 ? pos + 700 : close);
100
+ }
101
+
102
+ function runStateAriaPresent(input: AxisCheckInput): EvalIssue[] {
103
+ if (input.compiledCode === null) return [];
104
+ const src = input.sourceCode;
105
+ const setters = collectSetters(src);
106
+ const handlers = collectHandlers(src, setters);
107
+ const issues: EvalIssue[] = [];
108
+ const flaggedLines = new Set<number>();
109
+
110
+ // Locally-defined components: their CALL SITES can never satisfy the
111
+ // check (the ARIA lives inside the definition, which this check
112
+ // already covers via the callback-prop clause) — flagging the call
113
+ // site is a permanently-unresolvable finding that stalls the
114
+ // eval-fix loop in the recurrence guard (Experiment 54: exactly the
115
+ // 2/27 stuck cells). Skip call sites of components defined in this
116
+ // file; the definition's own inner element carries the check.
117
+ const localComponents = new Set<string>();
118
+ for (const m of src.matchAll(/(?:function\s+([A-Z]\w*)\s*\(|const\s+([A-Z]\w*)\s*=)/g)) {
119
+ localComponents.add(m[1] ?? m[2]);
120
+ }
121
+
122
+ for (const m of src.matchAll(/<([A-Za-z][A-Za-z0-9]*)\b/g)) {
123
+ const tag = m[1];
124
+ if (m.index === undefined) continue;
125
+ if (localComponents.has(tag)) continue;
126
+ const { block, end } = attrBlock(src, m.index + m[0].length);
127
+ const clickValue = onClickValue(block);
128
+ if (clickValue === null) continue;
129
+ const mutatesAffordance =
130
+ [...setters].some((s) => clickValue.includes(s)) ||
131
+ [...handlers].some((h) => new RegExp(`\\b${h}\\b`).test(clickValue)) ||
132
+ // Extracted-subcomponent shape: the toggle handler arrives as an
133
+ // `on[A-Z]…` callback prop and is invoked from onClick — inside a
134
+ // ui-affordance component that IS the affordance mutation.
135
+ /\bon[A-Z]\w*\b/.test(clickValue);
136
+ if (!mutatesAffordance) continue;
137
+ if (SEMANTIC_PRIMITIVES.has(tag)) continue;
138
+ if (STATE_ARIA_OR_ROLE.test(block)) continue;
139
+ if (STATE_PRIMITIVE_IN_SUBTREE.test(subtree(src, end, tag))) continue;
140
+
141
+ const line = src.slice(0, m.index).split("\n").length;
142
+ if (flaggedLines.has(line)) continue;
143
+ flaggedLines.add(line);
144
+ issues.push(
145
+ mkIssue(
146
+ "state.ui_affordance.state_aria_present",
147
+ `Line ${line}: <${tag}> toggles the UI affordance from its onClick but exposes no state ARIA — its checked/selected/expanded state is invisible to assistive technology (styling is the only carrier).`,
148
+ `Add the matching state attribute to the SAME element that carries the onClick: \`role="checkbox"\` + \`aria-checked={…}\` for a toggle row, \`aria-pressed\` for a toggle button, \`aria-selected\` for a selection, \`aria-expanded\` for an open/close affordance — or replace the hand-rolled control with the state-carrying primitive (Checkbox / Toggle / Tabs / Accordion). \`as={Clickable}\` adds button + keyboard semantics but NOT state.`,
149
+ ),
150
+ );
151
+ }
152
+ return issues;
153
+ }
154
+
155
+ export const STATE_UI_AFFORDANCE_CHECKS: readonly AxisCheck[] = [
156
+ {
157
+ id: "state.ui_affordance.state_aria_present",
158
+ axis: "state",
159
+ values: ["ui-affordance"],
160
+ run: runStateAriaPresent,
161
+ },
162
+ ];
@@ -86,6 +86,39 @@ function runNoPhantomUseState(input: AxisCheckInput): EvalIssue[] {
86
86
  return issues;
87
87
  }
88
88
 
89
+ function runPropSeedNoResync(input: AxisCheckInput): EvalIssue[] {
90
+ if (input.compiledCode === null) return [];
91
+ const src = input.sourceCode;
92
+ const issues: EvalIssue[] = [];
93
+ const re =
94
+ /const\s*\[\s*(\w+)\s*,\s*(\w+)\s*\]\s*=\s*useState(?:<[^>]*>)?\s*\(\s*props\??\.(\w+)/g;
95
+ for (const m of src.matchAll(re)) {
96
+ const [full, stateVar, setter, propName] = m;
97
+ const idx = m.index ?? 0;
98
+ const after = src.slice(idx + full.length);
99
+ // Never-called setter is `no_prop_mirror`'s case — this check is
100
+ // its #480 complement: the setter IS used (real optimistic state)
101
+ // but nothing re-seeds when the agent repaints the prop.
102
+ if (!new RegExp(`\\b${setter}\\s*\\(`).test(after)) continue;
103
+ // A re-seed effect names the prop in a dependency array. Bracket
104
+ // shape is the pragmatic signal (same looseness as the sibling
105
+ // checks): any `[... props.X ...]` array in the source counts.
106
+ const resync = new RegExp(
107
+ `\\[[^\\[\\]]*props\\??\\.${propName}\\b[^\\[\\]]*\\]`,
108
+ );
109
+ if (resync.test(src)) continue;
110
+ issues.push(
111
+ mkIssue(
112
+ "universal.prop_seed_no_resync",
113
+ `useState(props.${propName}) seeds "${stateVar}" and mutates it, but no effect depends on [props.${propName}] — a ggui_amend repaint pushes new props that never reach this display (#480).`,
114
+ `Add \`useEffect(() => ${setter}(props.${propName}), [props.${propName}]);\` so agent repaints re-seed the state, or derive the display directly from props.${propName}.`,
115
+ "warn",
116
+ ),
117
+ );
118
+ }
119
+ return issues;
120
+ }
121
+
89
122
  export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
90
123
  {
91
124
  id: "universal.prop_coverage",
@@ -105,4 +138,10 @@ export const UNIVERSAL_CHECKS: readonly AxisCheck[] = [
105
138
  values: ALL_RENDER_VALUES,
106
139
  run: runNoPhantomUseState,
107
140
  },
141
+ {
142
+ id: "universal.prop_seed_no_resync",
143
+ axis: "render",
144
+ values: ALL_RENDER_VALUES,
145
+ run: runPropSeedNoResync,
146
+ },
108
147
  ];