@adia-ai/a2ui-compose 0.7.27 → 0.7.28

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/CHANGELOG.md CHANGED
@@ -1,4 +1,84 @@
1
1
  # Changelog — @adia-ai/a2ui-compose
2
+ ## [0.7.28] — 2026-07-14
3
+
4
+ A2UI pipeline overhaul (comparing gen-ui-kit's system against a sibling
5
+ project's from-scratch A2UI implementation surfaced a concrete gap: only
6
+ one of six generation strategies ran a genuine closed-loop validate→retry
7
+ before returning; the rest either skipped it, did a single repair shot,
8
+ or validated a narrower plan/grounding contract).
9
+
10
+ ### Added
11
+
12
+ - **`packages/a2ui/compose/shared/validate-and-repair.js`** — a shared
13
+ closed-loop validate→repair helper, extracted from `generate-thinking`'s
14
+ original inline loop (schema + Ajv catalog + anti-pattern conformance,
15
+ bounded LLM-fed repair attempts). Every LLM-calling engine can now share
16
+ one implementation instead of hand-rolling its own; escalation policy
17
+ (retry vs. give up vs. escalate to a bigger tier) stays entirely with
18
+ the caller — the module only reports `{valid, exhausted, attempts,
19
+ failures}`.
20
+
21
+ ### Fixed
22
+
23
+ - **`generate-pro` — replaces its single hardcoded repair shot with the
24
+ shared closed loop** (`maxAttempts: 2`, reflecting pro's cheaper cost
25
+ tier vs. thinking's 3) and adds Ajv catalog-schema coverage it never had
26
+ before.
27
+ - **`free-form-composer` — now runs a real schema/catalog/anti-pattern
28
+ check (validate-only) on its transpiled output**, which it previously
29
+ never did at all (a hardcoded `score: 85` regardless of actual
30
+ validity). This surfaced a real, previously-invisible defect: the
31
+ synthesized root component used `id: 'free-form-root'` instead of the
32
+ `'root'` convention every other engine's root uses, which
33
+ `validator.js`'s `checkHasRootComponent` hard-fails on — fixed at the
34
+ source (`transpile.js`). `score` stays 85 on the success path
35
+ (unchanged), so the tuned `eval:diff` floors don't shift as a side
36
+ effect of adding a measurement; new `schemaValid`/`schemaValidation`
37
+ fields are additive.
38
+ - **`monolithic-instant` no longer silently returns a hard-fail's broken
39
+ output.** The dispatcher (`strategies/registry.js`'s
40
+ `generateInstantAdapter`) now escalates a hard-fail to `generate-pro`
41
+ exactly once, only when an `llmAdapter` is available — capped at one
42
+ hop by construction (a pro-mode failure returns straight to the
43
+ original caller, never chains to thinking). A counter +
44
+ `console.warn` ship from day one, since this silently converts a
45
+ 0-cost request into a billed LLM call.
46
+ - **`chunk-zettel` now emits a real component graph and qualifies for
47
+ the shared closed loop (TKT-0009).** `composeFromIntent`
48
+ (`chunk-synthesizer.js`) previously returned only a raw HTML string,
49
+ which both callers wrapped into a single node with an unregistered
50
+ `component: 'article'` type — full schema validation could only ever
51
+ report `invalid`. Ratified direction: reuse `transpileHTML()` (the
52
+ same converter `harvest-chunks.mjs` already runs at corpus-admission
53
+ time) to produce a real `template` component array on every path (a
54
+ chunk's own precomputed template when available, else transpiled on
55
+ demand). Both callers (`registry.js`'s `generateChunkZettelAdapter`,
56
+ `generator-adapter.js`'s `bridgeToChunkSynthesis`) now prefer
57
+ `result.template` and route the final messages through
58
+ `validate-and-repair.js` (validate-only). Verified live: Tier-1
59
+ retrieval and Tier-2 synthesis intents both produce `valid:true`
60
+ component graphs (score 90/92); zero regression confirmed via a
61
+ stash-based before/after comparison of `eval:compose-from-chunks`
62
+ (the eval calls `composeFromIntent` directly and never touches the
63
+ caller code this change modified).
64
+ - **`transpiler/transpiler-maps.js` — dynamic array props (`Chart.data`,
65
+ `Table.data`) are now actually parsed, not copied through as raw
66
+ strings (TKT-0010).** Compiled dynamic props are a bare `$ref`
67
+ (`DynamicString`/`DynamicObjectList`/etc.) with no inline `type`, so
68
+ `CATALOG_PROPS`'s `pdef.type || 'string'` silently defaulted every
69
+ dynamic array prop to string extraction — a declarative
70
+ `data='[{...}]'` attribute's JSON text was copied through unparsed,
71
+ never `JSON.parse`d, so it could never satisfy the catalog's
72
+ array-shaped schema. Fixed by resolving `$ref` names to their
73
+ primitive kind (new `REF_TYPE_MAP`) and adding a `JSON.parse`-ing
74
+ array extraction branch (skipping silently on parse failure or a
75
+ non-array result, matching `chart.yaml`'s own documented "non-array
76
+ renders empty" contract). Root-caused via the corpus admission
77
+ validator surfacing the same `Chart`/`Table`.`data` `oneOf` failure
78
+ across 8+ unrelated chunks — a schema-generation/transpiler bug, not
79
+ independent content authoring mistakes. See `@adia-ai/a2ui-corpus`'s
80
+ CHANGELOG for the companion schema-side fix (`DynamicObjectList`).
81
+
2
82
  ## [0.7.27] — 2026-07-12
3
83
 
4
84
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adia-ai/a2ui-compose",
3
- "version": "0.7.27",
3
+ "version": "0.7.28",
4
4
  "description": "AdiaUI A2UI compose engine \u2014 framework-agnostic. Takes natural-language intents + a catalog and produces A2UI protocol messages. Pairs with `@adia-ai/a2ui-retrieval` (intent classification, catalog lookup) and `@adia-ai/a2ui-validator` (schema + semantic checks).",
5
5
  "type": "module",
6
6
  "exports": {
@@ -293,7 +293,7 @@ describe('transpilePlan — root container (§103 auto-grouping)', () => {
293
293
  }, lookup);
294
294
  const root = result.messages[0].components[0];
295
295
  expect(root.component).toBe('Column');
296
- expect(root.id).toBe('free-form-root');
296
+ expect(root.id).toBe('root');
297
297
  expect(root.wrap).toBeUndefined();
298
298
  expect(root.columns).toBeUndefined();
299
299
  });
@@ -490,3 +490,25 @@ describe('transpilePlan — multi-attribute substitutions (§105 v0.5.0)', () =>
490
490
  expect(node.text).toBe('42');
491
491
  });
492
492
  });
493
+
494
+ // A2UI overhaul (Phase 3): full schema/anti-pattern/catalog check now runs
495
+ // on the transpiled result — previously this engine reported a hardcoded
496
+ // score=85 with NO real validation behind it at all.
497
+ describe('free-form-composer — schema validation (Phase 3 addition)', () => {
498
+ it('a validly-transpiled composition reports schemaValid: true, score stays 85 (unchanged eval-floor contract)', async () => {
499
+ const fakeLLM = {
500
+ complete: async () => ({
501
+ content: JSON.stringify({ ingredients: [{ name: 'demo-login' }], rationale: 'ok' }),
502
+ }),
503
+ };
504
+ const result = await generateFreeForm({
505
+ intent: 'sign me in',
506
+ llmAdapter: fakeLLM,
507
+ compositions: FIXTURE_VOCAB,
508
+ });
509
+ expect(result.strategy).toBe('free-form-composed');
510
+ expect(result.validation.score).toBe(85);
511
+ expect(result.schemaValid).toBe(true);
512
+ expect(result.schemaValidation).toBeTruthy();
513
+ });
514
+ });
@@ -33,6 +33,7 @@ import {
33
33
  buildFreeFormParaphraseRetry,
34
34
  } from './system-prompt.js';
35
35
  import { transpilePlan, parsePlan } from './transpile.js';
36
+ import { validateAndRepair } from '../../shared/validate-and-repair.js';
36
37
 
37
38
  const MAX_ATTEMPTS = 2;
38
39
 
@@ -209,9 +210,27 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
209
210
  };
210
211
  }
211
212
 
212
- // Score reflects "we successfully composed N ingredients without
213
- // hallucination". Validator-level scoring is upstream's responsibility.
214
- // 85 chosen to match zettel's `composition-match` avgScore baseline.
213
+ // Full schema/catalog/anti-pattern conformance the same final pass
214
+ // every other LLM-calling engine runs (see validate-and-repair.js) —
215
+ // ADDED here, not previously run at all; this engine used to report a
216
+ // hardcoded score=85 with no real validation behind it whatsoever.
217
+ // Validate-ONLY (no llmAdapter passed): a repair attempt here would mean
218
+ // re-authoring the ingredient PLAN, not the transpiled A2UI JSON
219
+ // directly — this engine's whole paradigm is ingredient selection +
220
+ // deterministic transpilation, not direct JSON authoring, so a schema-
221
+ // failure repair prompt has no natural target to hand back to the LLM.
222
+ // The existing hallucination-guard retry loop above (ingredient-name
223
+ // grounding) is this engine's own repair mechanism.
224
+ //
225
+ // score stays 85 on the success path — unchanged from before — so the
226
+ // eval:diff floors (cov/avg/F1) tuned against that number don't shift as
227
+ // a side effect of adding a measurement; schemaValid/schemaScore/
228
+ // antiPatternFailures are new, additive fields a future increment (or
229
+ // the eval baseline itself, deliberately) can act on.
230
+ const schemaCheck = await validateAndRepair({ messages });
231
+ if (!schemaCheck.valid) {
232
+ warnings = warnings.concat([`schema/anti-pattern check failed (score ${schemaCheck.validation.score}): ${schemaCheck.validation.checks?.filter(c => !c.passed).map(c => c.name).join(', ') || 'see schemaValidation'}`]);
233
+ }
215
234
  const score = 85;
216
235
 
217
236
  return {
@@ -224,5 +243,7 @@ Note: your previous response wasn't valid JSON. Output ONLY the JSON object —
224
243
  warnings,
225
244
  rationale: plan.rationale || null,
226
245
  emptyPlanRetryUsed,
246
+ schemaValid: schemaCheck.valid,
247
+ schemaValidation: schemaCheck.validation,
227
248
  };
228
249
  }
@@ -220,7 +220,12 @@ function buildRoot(plan, rootChildren, warnings) {
220
220
  }
221
221
 
222
222
  const base = {
223
- id: 'free-form-root',
223
+ // 'root' — not 'free-form-root' — matches the convention every other
224
+ // engine's root component uses (validator.js's checkHasRootComponent
225
+ // hard-fails without it: "at least one component with id: 'root'").
226
+ // This engine never ran real schema validation before Phase 3 of the
227
+ // A2UI overhaul plan, so the mismatch went unnoticed until then.
228
+ id: 'root',
224
229
  gap: '6',
225
230
  children: rootChildren,
226
231
  };
@@ -6,7 +6,7 @@
6
6
  * in strategies/monolithic/_shared.js.
7
7
  */
8
8
 
9
- import { validateSchema } from '../../../validator/validator.js';
9
+ import { validateAndRepair } from '../../shared/validate-and-repair.js';
10
10
  import { getContext, searchBlocks, lookupDomain } from '../../core/reference.js';
11
11
  import { decomposeIntent } from '../../../retrieval/intent/decomposer.js';
12
12
  import { assessClarity } from '../../../retrieval/intent/clarity.js';
@@ -403,18 +403,26 @@ ${buildCanvasDiffPrompt(intent, priorComponents, { originalIntent })}`;
403
403
  });
404
404
 
405
405
  // ── Stage 5: Validate + repair ──
406
- let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
407
- if (!validation.valid) {
408
- try {
409
- const failedChecks = validation.checks.filter(c => !c.passed);
410
- const repairResponse = await llmAdapter.complete({
411
- messages: [{ role: 'user', content: buildRepairPrompt(failedChecks, messages) }],
412
- systemPrompt,
413
- });
414
- messages = parseA2UIResponse(repairResponse.content, { executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason });
415
- validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
416
- } catch { /* keep original */ }
417
- }
406
+ // Shared with every other LLM-calling engine see validate-and-repair.js.
407
+ // maxAttempts: 2 (vs. thinking's 3) reflects pro's cheaper cost tier.
408
+ // runCatalogCheck adds Ajv catalog-schema coverage pro never had before —
409
+ // buildRepairPrompt's catalogFailures param already defaults to [] so
410
+ // this is additive, not a signature break.
411
+ const repairResult = await validateAndRepair({
412
+ messages,
413
+ llmAdapter,
414
+ buildRepairPrompt,
415
+ parseResponse: (repairResponse) => parseA2UIResponse(
416
+ repairResponse.content,
417
+ { executionId: execId, intent, mode: 'pro', stopReason: repairResponse.stopReason },
418
+ ),
419
+ systemPrompt,
420
+ validateContext: { intent, context },
421
+ foldInAntiPatterns,
422
+ maxAttempts: 2,
423
+ });
424
+ messages = repairResult.messages;
425
+ const validation = repairResult.validation;
418
426
  engine.submitStage(execId, 'validate', { ...validation, confidence: validation.score / 100 });
419
427
 
420
428
  // ── Stage 6: Store ──
@@ -6,8 +6,7 @@
6
6
  * in strategies/monolithic/_shared.js.
7
7
  */
8
8
 
9
- import { validateSchema } from '../../../validator/validator.js';
10
- import { validateMessages as validateCatalog } from '../../../validator/catalog-validator.js';
9
+ import { validateAndRepair } from '../../shared/validate-and-repair.js';
11
10
  import { getContext, searchBlocksSemantic, lookupDomain } from '../../core/reference.js';
12
11
  import { assessClarity } from '../../../retrieval/intent/clarity.js';
13
12
  import { feedbackStore } from '../../../retrieval/feedback/feedback-store.js';
@@ -122,35 +121,32 @@ export async function generateThinking({ intent, executionId, storeId, llmAdapte
122
121
  });
123
122
 
124
123
  // ── Stage 5: Validate + repair loop ──
125
- // Two orthogonal validators run in parallel:
126
- // - scored: weighted heuristic checks (intent alignment, card model, etc.)
127
- // - catalog: AJV against v0.9 catalog schema (strict structural correctness)
128
- // Either failing triggers a repair attempt. Catalog failures are typically
129
- // small (unknown prop, wrong enum, missing required id) and fix cleanly in
130
- // one repair round. Three-attempt cap preserved from the original loop.
131
- let validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
132
- let catalogValidation = await validateCatalog(messages);
133
- let attempts = 0;
134
-
135
- while ((!validation.valid || !catalogValidation.valid) && attempts < 3) {
136
- attempts++;
137
- const failedChecks = validation.checks.filter(c => !c.passed);
138
- const catalogFailures = catalogValidation.failures || [];
139
- const repairPrompt = buildRepairPrompt(failedChecks, messages, catalogFailures);
140
-
141
- try {
142
- const repairResponse = await llmAdapter.complete({
143
- messages: [{ role: 'user', content: repairPrompt }],
144
- systemPrompt,
145
- });
146
- messages = parseA2UIResponse(repairResponse.content, { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason });
124
+ // Two orthogonal validators run: scored (weighted heuristic checks —
125
+ // intent alignment, card model, etc.) and catalog (AJV against v0.9
126
+ // catalog schema strict structural correctness). Either failing
127
+ // triggers a repair attempt. Catalog failures are typically small
128
+ // (unknown prop, wrong enum, missing required id) and fix cleanly in one
129
+ // repair round. Three-attempt cap preserved from the original loop.
130
+ // Shared with every other LLM-calling engine see validate-and-repair.js.
131
+ const repairResult = await validateAndRepair({
132
+ messages,
133
+ llmAdapter,
134
+ buildRepairPrompt,
135
+ parseResponse: (repairResponse) => parseA2UIResponse(
136
+ repairResponse.content,
137
+ { executionId: execId, mode: 'thinking', intent, stopReason: repairResponse.stopReason },
138
+ ),
139
+ systemPrompt,
140
+ validateContext: { intent, context },
141
+ foldInAntiPatterns,
142
+ maxAttempts: 3,
143
+ onAttempt: (repairResponse) => {
147
144
  if (isRecording()) { lastRawResponse = repairResponse.content; lastTokens = repairResponse.usage || lastTokens; }
148
- validation = foldInAntiPatterns(messages, validateSchema(messages, { intent, context }));
149
- catalogValidation = await validateCatalog(messages);
150
- } catch {
151
- break; // stop repair loop on adapter error
152
- }
153
- }
145
+ },
146
+ });
147
+ messages = repairResult.messages;
148
+ const validation = repairResult.validation;
149
+ const attempts = repairResult.attempts;
154
150
 
155
151
  engine.submitStage(execId, 'validate', {
156
152
  ...validation,
@@ -52,11 +52,42 @@ export function registerMonolithicEngines({ instant, pro, thinking }) {
52
52
  _monolithicThinking = thinking;
53
53
  }
54
54
 
55
+ // A2UI overhaul plan, Phase 4: instant mode never repairs (it makes no LLM
56
+ // calls at all — that's its whole cost/latency identity) and previously
57
+ // returned a hard-fail's broken output silently. Escalation to pro lives
58
+ // HERE, at the dispatcher, not inside generate-instant.js itself — it's the
59
+ // caller's policy decision, matching validate-and-repair.js's "escalation
60
+ // stays with the caller" contract, and this is the one place that already
61
+ // holds both `ctx.llmAdapter` and `_monolithicPro` in scope.
62
+ //
63
+ // Capped at exactly one hop by construction: generateProAdapter has no
64
+ // escalation logic of its own, so a pro-mode failure after an instant
65
+ // escalation returns straight to the original caller — it can never chain
66
+ // into thinking. Only escalates when an llmAdapter is actually available;
67
+ // many instant-mode callers pass none (that's the 0-cost tier's point), and
68
+ // validate-and-repair.js's own contract for that case is "report the
69
+ // failure, don't invent a repair path" — same discipline here.
70
+ //
71
+ // This silently converts a 0-cost request into a billed LLM call, which is
72
+ // a product/cost-visibility concern, not just an engineering one — hence
73
+ // the counter + the warn log, from day one, not added later once someone
74
+ // asks "why did our LLM spend spike".
75
+ let _instantEscalationCount = 0;
76
+ export function getInstantEscalationCount() {
77
+ return _instantEscalationCount;
78
+ }
79
+
55
80
  // Engine adapters — uniform call signature `(ctx) => Promise<result>` where
56
81
  // ctx is prepared by the public generateUI() orchestrator.
57
82
  async function generateInstantAdapter(ctx) {
58
83
  if (!_monolithicInstant) throw new Error('monolithic-instant engine not registered');
59
- return _monolithicInstant(ctx);
84
+ const result = await _monolithicInstant(ctx);
85
+ if (!result.validation?.valid && ctx.llmAdapter && _monolithicPro) {
86
+ _instantEscalationCount++;
87
+ console.warn(`[a2ui] monolithic-instant hard-failed validation (score=${result.validation?.score}) for intent "${ctx.intent}" — escalating to monolithic-pro (escalation #${_instantEscalationCount})`);
88
+ return _monolithicPro(ctx);
89
+ }
90
+ return result;
60
91
  }
61
92
  async function generateProAdapter(ctx) {
62
93
  if (!_monolithicPro) throw new Error('monolithic-pro engine not registered');
@@ -230,27 +261,42 @@ async function generateFreeFormAdapter(ctx) {
230
261
 
231
262
  async function generateChunkZettelAdapter(ctx) {
232
263
  const { composeFromIntent } = await import('./zettel/chunk-synthesizer.js');
264
+ const { validateAndRepair } = await import('../shared/validate-and-repair.js');
233
265
  const result = await composeFromIntent({
234
266
  intent: ctx.intent,
235
267
  llmAdapter: ctx.llmAdapter || null,
236
268
  maxAttempts: 2,
237
269
  });
238
270
 
239
- // Convert chunk-composition result to A2UI message shape.
240
- const messages = result.html
241
- ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
242
- : [];
271
+ // TKT-0009: composeFromIntent now returns a real component array
272
+ // (`result.template`) whenever it can, instead of only an HTML string —
273
+ // use it directly; the pre-fix fallback (a single node carrying an
274
+ // UNREGISTERED `component: 'article'` type) only fires if transpilation
275
+ // itself somehow produced nothing, which should not happen in practice.
276
+ const messages = result.template
277
+ ? [{ type: 'updateComponents', components: result.template }]
278
+ : result.html
279
+ ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
280
+ : [];
281
+
282
+ // Full schema/catalog/anti-pattern conformance — the same final pass
283
+ // every other engine runs (see validate-and-repair.js). Validate-only:
284
+ // no natural repair target here (the LLM authored a slot-binding PLAN,
285
+ // not this A2UI JSON directly) — chunk-composer's own `validatePlan`
286
+ // is this engine's repair-relevant check and already ran upstream.
287
+ const schemaCheck = messages.length ? await validateAndRepair({ messages }) : { valid: false, validation: { score: 0 } };
243
288
 
244
289
  return {
245
290
  executionId: ctx.executionId,
246
291
  messages,
247
- validation: { score: result.html ? 70 : 0 },
292
+ validation: schemaCheck.validation,
248
293
  strategy: result.source === 'retrieval' ? 'chunk-retrieval' : 'chunk-synthesis',
249
294
  engine: 'chunk-zettel',
250
295
  _debug: {
251
296
  plan: result.plan || null,
252
297
  warnings: result.warnings || [],
253
298
  scopeDrift: result.scopeDrift || null,
299
+ schemaValid: schemaCheck.valid,
254
300
  },
255
301
  };
256
302
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * registry.js — instant→pro escalation (A2UI overhaul plan, Phase 4).
3
+ *
4
+ * Instant mode makes zero LLM calls and previously returned a hard-fail's
5
+ * broken output silently. The dispatcher now escalates to pro exactly once
6
+ * when an llmAdapter is available; escalation policy lives here, not inside
7
+ * generate-instant.js itself (see registry.js's comment above
8
+ * generateInstantAdapter).
9
+ */
10
+ import { describe, it, expect, beforeEach } from 'vitest';
11
+ import { registerMonolithicEngines, ENGINES, getInstantEscalationCount } from './registry.js';
12
+
13
+ const HARD_FAIL_RESULT = { messages: [], validation: { valid: false, score: 0 }, strategy: 'fallback' };
14
+ const VALID_RESULT = { messages: [{ type: 'updateComponents', components: [{ id: 'root', component: 'Card' }] }], validation: { valid: true, score: 90 }, strategy: 'pattern-match' };
15
+
16
+ beforeEach(() => {
17
+ // Reset to known fakes before every case — registerMonolithicEngines is
18
+ // the same public injection API core/generator.js uses at boot.
19
+ registerMonolithicEngines({ instant: null, pro: null, thinking: null });
20
+ });
21
+
22
+ describe('generateInstantAdapter — escalation to pro on hard-fail', () => {
23
+ it('does NOT escalate when instant mode already returns a valid result', async () => {
24
+ let proCalls = 0;
25
+ registerMonolithicEngines({
26
+ instant: async () => VALID_RESULT,
27
+ pro: async () => { proCalls++; return VALID_RESULT; },
28
+ thinking: async () => VALID_RESULT,
29
+ });
30
+ const before = getInstantEscalationCount();
31
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
32
+ expect(result).toBe(VALID_RESULT);
33
+ expect(proCalls).toBe(0);
34
+ expect(getInstantEscalationCount()).toBe(before);
35
+ });
36
+
37
+ it('escalates to pro exactly once when instant hard-fails AND an llmAdapter is available', async () => {
38
+ let proCalls = 0;
39
+ registerMonolithicEngines({
40
+ instant: async () => HARD_FAIL_RESULT,
41
+ pro: async (ctx) => { proCalls++; return { ...VALID_RESULT, receivedCtx: ctx }; },
42
+ thinking: async () => VALID_RESULT,
43
+ });
44
+ const before = getInstantEscalationCount();
45
+ const fakeAdapter = { complete: async () => ({}) };
46
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', executionId: 'exec-1', storeId: 'store-1', llmAdapter: fakeAdapter });
47
+ expect(proCalls).toBe(1);
48
+ expect(result.validation.valid).toBe(true);
49
+ expect(getInstantEscalationCount()).toBe(before + 1);
50
+ // Same ctx (storeId/executionId/llmAdapter) threaded through unmodified —
51
+ // no session/store desync risk from a reconstructed context.
52
+ expect(result.receivedCtx.executionId).toBe('exec-1');
53
+ expect(result.receivedCtx.storeId).toBe('store-1');
54
+ expect(result.receivedCtx.llmAdapter).toBe(fakeAdapter);
55
+ });
56
+
57
+ it('does NOT escalate when instant hard-fails but no llmAdapter is available — reports the failure as-is', async () => {
58
+ let proCalls = 0;
59
+ registerMonolithicEngines({
60
+ instant: async () => HARD_FAIL_RESULT,
61
+ pro: async () => { proCalls++; return VALID_RESULT; },
62
+ thinking: async () => VALID_RESULT,
63
+ });
64
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: null });
65
+ expect(proCalls).toBe(0);
66
+ expect(result).toBe(HARD_FAIL_RESULT);
67
+ });
68
+
69
+ it('caps escalation at exactly one hop — a pro-mode failure never chains to thinking', async () => {
70
+ let proCalls = 0;
71
+ let thinkingCalls = 0;
72
+ registerMonolithicEngines({
73
+ instant: async () => HARD_FAIL_RESULT,
74
+ pro: async () => { proCalls++; return HARD_FAIL_RESULT; }, // pro ALSO fails
75
+ thinking: async () => { thinkingCalls++; return VALID_RESULT; },
76
+ });
77
+ const result = await ENGINES['monolithic-instant']({ intent: 'x', llmAdapter: { complete: async () => ({}) } });
78
+ expect(proCalls).toBe(1);
79
+ expect(thinkingCalls).toBe(0); // never chains further — pro's own failure returns to the original caller
80
+ expect(result.validation.valid).toBe(false);
81
+ });
82
+ });
@@ -27,6 +27,29 @@ import {
27
27
  getAllChunks,
28
28
  } from '../../../corpus/scripts/chunk-library.js';
29
29
  import { composeFromPlan, validatePlan } from './chunk-composer.js';
30
+ import { transpileHTML } from '../../transpiler/transpiler.js';
31
+
32
+ // TKT-0009: composeFromIntent used to return only a raw HTML string,
33
+ // which every caller wrapped in a single node carrying an UNREGISTERED
34
+ // A2UI type (`component: 'article'`) — meaning full schema/catalog
35
+ // validation against it always reported invalid, regardless of the
36
+ // composed content's actual quality (registry.has('article') is false).
37
+ // Ratified direction: reuse the SAME transpileHTML() infrastructure the
38
+ // docs-transpiler and harvest-chunks.mjs already run at scale, rather
39
+ // than inventing a new passthrough construct — this makes every
40
+ // composeFromIntent() result a REAL component graph, so it qualifies for
41
+ // the shared validate-and-repair loop like every other engine. A chunk's
42
+ // own precomputed `.template` (from harvest-chunks.mjs) is used directly
43
+ // when available (accurate, no re-transpile cost); multi-instance chunk
44
+ // records don't carry one per-instance, so this falls back to
45
+ // transpiling the HTML on demand either way — every return path leaves
46
+ // with a real template, never just a string.
47
+ async function ensureTemplate(html, precomputedTemplate) {
48
+ if (Array.isArray(precomputedTemplate) && precomputedTemplate.length) return precomputedTemplate;
49
+ if (!html) return null;
50
+ const { messages } = await transpileHTML(html);
51
+ return messages?.[0]?.components ?? null;
52
+ }
30
53
 
31
54
  const STRONG_RETRIEVAL_SCORE = 8; // search-score threshold for fast path
32
55
  const PRE_SEARCH_LIMIT = 30; // chunks shown to the LLM in the prompt
@@ -199,7 +222,11 @@ function extractJSON(raw) {
199
222
  * @param {string} opts.intent
200
223
  * @param {object} opts.llmAdapter — { complete: async ({ messages, systemPrompt }) => { content|text } }
201
224
  * @param {number} [opts.maxAttempts=2]
202
- * @returns {Promise<{ html: string, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
225
+ * @returns {Promise<{ html: string, template: object[]|null, plan: object|null, source: 'retrieval'|'synthesis', score?: number, warnings: string[], synthesis?: object }>}
226
+ * `template` (TKT-0009) is a real A2UI component array — derived from the
227
+ * chunk's own precomputed template when available, else transpiled from
228
+ * `html` on demand — so callers get a validatable component graph, not
229
+ * just an HTML string to wrap opaquely.
203
230
  */
204
231
  export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFAULT_MAX_ATTEMPTS }) {
205
232
  // Tier 1 — retrieval. Try semantic-blended hit first; fall back to keyword
@@ -228,6 +255,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
228
255
  const scopeDrift = computeScopeDrift(html, [top]);
229
256
  return {
230
257
  html,
258
+ template: await ensureTemplate(html, top.template),
231
259
  plan: null,
232
260
  source: 'retrieval',
233
261
  score: hit.score,
@@ -339,6 +367,7 @@ export async function composeFromIntent({ intent, llmAdapter, maxAttempts = DEFA
339
367
 
340
368
  return {
341
369
  html: composed.html,
370
+ template: await ensureTemplate(composed.html, null),
342
371
  plan,
343
372
  source: 'synthesis',
344
373
  warnings: [...composed.warnings, ...driftWarnings],
@@ -47,11 +47,18 @@ import { validateSchema } from '../../../validator/validator.js';
47
47
  async function bridgeToChunkSynthesis({ intent, llmAdapter }) {
48
48
  const { composeFromIntent } = await import('./chunk-synthesizer.js');
49
49
  const result = await composeFromIntent({ intent, llmAdapter, maxAttempts: 2 });
50
- // Shape-adapt chunk-synthesizer's `{ html, plan, source, ... }` to the
51
- // synthesis-branch's prior `{ messages, template, synthesis }` contract.
52
- const messages = result.html
53
- ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
54
- : [];
50
+ // Shape-adapt chunk-synthesizer's `{ html, template, plan, source, ... }`
51
+ // to the synthesis-branch's prior `{ messages, template, synthesis }`
52
+ // contract. TKT-0009: prefer the real component array (`result.template`)
53
+ // over the pre-fix fallback of wrapping raw HTML in a single node with an
54
+ // UNREGISTERED `component: 'article'` type — that fallback made this
55
+ // caller's validate-and-repair pass below structurally unable to ever
56
+ // report a clean pass.
57
+ const messages = result.template
58
+ ? [{ type: 'updateComponents', components: result.template }]
59
+ : result.html
60
+ ? [{ type: 'updateComponents', components: [{ id: 'chunk-root', component: 'article', html: result.html }] }]
61
+ : [];
55
62
  return {
56
63
  messages,
57
64
  template: result.plan ? [{ $chunk: 'composeFromIntent', plan: result.plan }] : [],
@@ -172,7 +179,13 @@ export async function generateZettel({ intent, mode = 'instant', llmAdapter = nu
172
179
  if (llmAdapter && mode !== 'instant-only') {
173
180
  try {
174
181
  const synth = await bridgeToChunkSynthesis({ intent, llmAdapter });
175
- const validation = validateSchema(synth.messages, { intent });
182
+ // Full schema/catalog/anti-pattern conformance (validate-only see
183
+ // validate-and-repair.js; TKT-0009 made this meaningful for the first
184
+ // time by giving synth.messages a real component graph instead of an
185
+ // opaque HTML wrapper).
186
+ const { validateAndRepair } = await import('../../shared/validate-and-repair.js');
187
+ const schemaCheck = synth.messages.length ? await validateAndRepair({ messages: synth.messages }) : { valid: false, validation: { score: 0 } };
188
+ const validation = schemaCheck.validation;
176
189
  // Chunk-bridge produces an html-bearing single component, not
177
190
  // resolved fragment instances. `fragments_used` is empty by design;
178
191
  // consumers tracking fragment usage should switch to the
@@ -39,6 +39,21 @@ import { registry } from '@adia-ai/a2ui-runtime';
39
39
  // alias map below so `extractProps(el, 'CheckBox')` resolves to the
40
40
  // `Check` catalog entry.
41
41
 
42
+ // TKT-0010: dynamic props are compiled to a bare `$ref` (DynamicString /
43
+ // DynamicNumber / DynamicBoolean / DynamicStringList / DynamicObjectList),
44
+ // which carries no inline `type` — so `pdef.type || 'string'` below silently
45
+ // treated EVERY dynamic array prop (Chart.data, Table.data) as a string,
46
+ // copying the raw `data='[...]'` attribute text through unparsed instead of
47
+ // JSON-parsing it into a real array. Resolve the ref name to its primitive
48
+ // kind first so extractCatalogProps' array branch actually fires.
49
+ const REF_TYPE_MAP = {
50
+ DynamicString: 'string',
51
+ DynamicNumber: 'number',
52
+ DynamicBoolean: 'boolean',
53
+ DynamicStringList: 'array',
54
+ DynamicObjectList: 'array',
55
+ };
56
+
42
57
  /** @type {Map<string, {props: Map<string, {type: string, enum?: string[]}>, canonical: string}>} */
43
58
  const CATALOG_PROPS = (() => {
44
59
  const map = new Map();
@@ -50,8 +65,13 @@ const CATALOG_PROPS = (() => {
50
65
  // Skip internal/computed props that aren't HTML attributes
51
66
  if (pname === 'component' || pname === 'textContent') continue;
52
67
  if (typeof pdef !== 'object' || !pdef) continue;
68
+ let type = pdef.type;
69
+ if (!type && typeof pdef.$ref === 'string') {
70
+ const refName = pdef.$ref.split('/').pop();
71
+ type = REF_TYPE_MAP[refName];
72
+ }
53
73
  propMap.set(pname, {
54
- type: pdef.type || 'string',
74
+ type: type || 'string',
55
75
  enum: pdef.enum || null,
56
76
  });
57
77
  }
@@ -153,6 +173,18 @@ function extractCatalogProps(el, a2uiType) {
153
173
  if (raw === '') continue;
154
174
  const n = Number(raw);
155
175
  props[pname] = Number.isFinite(n) ? n : raw;
176
+ } else if (pdef.type === 'array') {
177
+ // TKT-0010: declarative JSON-array attribute (e.g. chart-ui/table-ui's
178
+ // data='[{...}]'), hydrated once at connect per those components' own
179
+ // yaml docs. Parse it — the raw string never satisfies the catalog's
180
+ // array schema, and per chart.yaml's own contract a non-array value
181
+ // is meant to render empty, so a parse failure/non-array is skipped,
182
+ // not passed through.
183
+ if (raw === '') continue;
184
+ try {
185
+ const parsed = JSON.parse(raw);
186
+ if (Array.isArray(parsed)) props[pname] = parsed;
187
+ } catch { /* not valid JSON — skip */ }
156
188
  } else {
157
189
  // string (incl. enum) — skip empty
158
190
  if (raw === '') continue;