@adia-ai/a2ui-mcp 0.0.4 → 0.1.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.
@@ -0,0 +1,266 @@
1
+ #!/usr/bin/env node
2
+ // Smoke test: issue-reporter (write, trace attach, auto-fire, coalesce, evalMode).
3
+ // Spec: docs/specs/genui-multiturn-architecture.md §3.5 + §4.6 + §6.4 + §11.
4
+
5
+ import {
6
+ reportIssue,
7
+ autoReport,
8
+ attachTrace,
9
+ createIssueAccumulator,
10
+ AUTO_FIRE_POLICY,
11
+ } from '../../compose/engines/zettel/issue-reporter.js';
12
+ import { StateCache } from '../../compose/engines/zettel/state-cache.js';
13
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+
17
+ let pass = 0, fail = 0;
18
+ const t = (label, ok, detail = '') => {
19
+ if (ok) { console.log(` ✓ ${label}`); pass++; }
20
+ else { console.log(` ✗ ${label} ${detail}`); fail++; }
21
+ };
22
+
23
+ const TMP = await mkdtemp(join(tmpdir(), 'a2ui-issues-'));
24
+ const ctx = {
25
+ storageRoot: TMP,
26
+ versionInfo: { mcp: '0.1.0', corpus: '0.0.6', engine: 'zettel', llm_adapter: 'stub', model: 'test' },
27
+ };
28
+
29
+ console.log(`Storage root: ${TMP}`);
30
+ console.log('\n=== reportIssue: basic write ===');
31
+
32
+ const r1 = await reportIssue({
33
+ type: 'bug',
34
+ severity: 'drift',
35
+ title: 'Test issue with simple title',
36
+ body: 'Test body content.',
37
+ }, { ...ctx, reporter: 'user' });
38
+
39
+ t('returns issue_id', !!r1.issue_id);
40
+ t('issue-id format YYYY-MM-DD-slug-rand4', /^\d{4}-\d{2}-\d{2}-[a-z0-9-]+-[a-f0-9]{4}$/.test(r1.issue_id));
41
+ t('returns ack: logged', r1.ack === 'logged');
42
+ t('returns absolute path', r1.path.startsWith(TMP));
43
+
44
+ const file1 = JSON.parse(await readFile(r1.path, 'utf8'));
45
+ t('written file has expected type', file1.type === 'bug');
46
+ t('written file has expected severity', file1.severity === 'drift');
47
+ t('written file has status open', file1.status === 'open');
48
+ t('written file has reporter.kind', file1.reporter.kind === 'user');
49
+ t('written file has environment', file1.environment?.mcp === '0.1.0');
50
+ t('written file has linked_specs default', Array.isArray(file1.linked_specs) && file1.linked_specs.length > 0);
51
+ t('related_issue_ids defaults to empty array', Array.isArray(file1.related_issue_ids) && file1.related_issue_ids.length === 0);
52
+ t('tags defaults to empty array', Array.isArray(file1.tags));
53
+ t('suggested_owner defaults to "unknown"', file1.suggested_owner === 'unknown');
54
+
55
+ console.log('\n=== validation guards ===');
56
+
57
+ let threw = false;
58
+ try { await reportIssue({ type: 'invalid', severity: 'drift', title: 't', body: 'b' }, ctx); }
59
+ catch (e) { threw = /type must be/.test(e.message); }
60
+ t('rejects invalid type', threw);
61
+
62
+ threw = false;
63
+ try { await reportIssue({ type: 'bug', severity: 'oops', title: 't', body: 'b' }, ctx); }
64
+ catch (e) { threw = /severity must be/.test(e.message); }
65
+ t('rejects invalid severity', threw);
66
+
67
+ threw = false;
68
+ try { await reportIssue({ type: 'bug', severity: 'drift', title: 'x'.repeat(81), body: 'b' }, ctx); }
69
+ catch (e) { threw = /≤ 80 chars/.test(e.message); }
70
+ t('rejects title > 80 chars', threw);
71
+
72
+ threw = false;
73
+ try { await reportIssue({ type: 'bug', severity: 'drift', title: 't', body: 'b', trace: 'partial' }, ctx); }
74
+ catch (e) { threw = /trace must be/.test(e.message); }
75
+ t('rejects invalid trace depth', threw);
76
+
77
+ threw = false;
78
+ try { await reportIssue({ type: 'bug', severity: 'drift', title: 't', body: 'b', tags: 'not-an-array' }, ctx); }
79
+ catch (e) { threw = /tags must be an array/.test(e.message); }
80
+ t('rejects non-array tags', threw);
81
+
82
+ console.log('\n=== state_id trace attachment ===');
83
+
84
+ const cache = new StateCache({ maxSize: 10 });
85
+ cache.set('dash-3f9a-v1-26042817', {
86
+ state_id: 'dash-3f9a-v1-26042817',
87
+ intent: 'admin dashboard',
88
+ tool: 'compose_from_chunks',
89
+ input: { intent: 'admin dashboard' },
90
+ output: { html: '<dashboard/>', plan: { page: 'dashboard-admin-page' } },
91
+ ops_history: [
92
+ { type: 'createSurface', surfaceId: 'main' },
93
+ { type: 'updateComponents', surfaceId: 'main', components: [] },
94
+ ],
95
+ delta_summary: 'Created admin dashboard',
96
+ warnings: [],
97
+ duration_ms: 1234,
98
+ internal: {
99
+ locator_prompt: 'PROMPT_LOCATOR_v1',
100
+ locator_response: 'RESPONSE_LOCATOR_v1',
101
+ modifier_prompt: 'PROMPT_MODIFIER_v1',
102
+ modifier_response: 'RESPONSE_MODIFIER_v1',
103
+ validator_results: [{ ok: true }],
104
+ retries: 0,
105
+ },
106
+ });
107
+
108
+ const traceSummary = await attachTrace('dash-3f9a-v1-26042817', 'summary', cache);
109
+ t('summary trace populates state_id', traceSummary?.state_id === 'dash-3f9a-v1-26042817');
110
+ t('summary trace populates input', traceSummary?.input?.intent === 'admin dashboard');
111
+ t('summary trace populates output.ops', Array.isArray(traceSummary?.output?.ops));
112
+ t('summary trace omits internal field', traceSummary?.internal === undefined);
113
+
114
+ const traceFull = await attachTrace('dash-3f9a-v1-26042817', 'full', cache);
115
+ t('full trace includes internal.locator_prompt', traceFull?.internal?.locator_prompt === 'PROMPT_LOCATOR_v1');
116
+ t('full trace includes internal.modifier_response', traceFull?.internal?.modifier_response === 'RESPONSE_MODIFIER_v1');
117
+ t('full trace includes validator_results', Array.isArray(traceFull?.internal?.validator_results));
118
+
119
+ const traceMiss = await attachTrace('not-a-real-id', 'summary', cache);
120
+ t('attachTrace returns null on cache miss', traceMiss === null);
121
+
122
+ const traceNoCache = await attachTrace('dash-3f9a-v1-26042817', 'summary', null);
123
+ t('attachTrace returns null when no cache', traceNoCache === null);
124
+
125
+ // reportIssue with state_id integrates trace
126
+ const r2 = await reportIssue({
127
+ type: 'bug',
128
+ severity: 'drift',
129
+ title: 'Issue tied to a state',
130
+ body: 'Reproducible on dashboard generation.',
131
+ state_id: 'dash-3f9a-v1-26042817',
132
+ trace: 'full',
133
+ }, { ...ctx, cache });
134
+ const file2 = JSON.parse(await readFile(r2.path, 'utf8'));
135
+ t('reportIssue with state_id+full attaches trace.input', file2.trace?.input?.intent === 'admin dashboard');
136
+ t('reportIssue with full trace attaches internal', file2.trace?.internal?.locator_prompt === 'PROMPT_LOCATOR_v1');
137
+
138
+ // peek does not touch recency — verify the cache state didn't bump dash- to most-recent.
139
+ // Insert a few more entries, ensure dash- stays the LRU candidate.
140
+ // (This is exercised indirectly: state-cache smoke covers peek-recency directly.)
141
+
142
+ console.log('\n=== oversized trace spills to sidecar ===');
143
+
144
+ cache.set('big-state', {
145
+ state_id: 'big-state',
146
+ intent: 'big',
147
+ tool: 'compose',
148
+ input: {},
149
+ ops_history: [],
150
+ internal: { huge_dump: 'x'.repeat(300 * 1024) },
151
+ });
152
+ const r3 = await reportIssue({
153
+ type: 'bug',
154
+ severity: 'drift',
155
+ title: 'Big trace test',
156
+ body: 'Has an oversized trace.',
157
+ state_id: 'big-state',
158
+ trace: 'full',
159
+ }, { ...ctx, cache });
160
+ const file3 = JSON.parse(await readFile(r3.path, 'utf8'));
161
+ t('oversized trace replaced by sidecar pointer', !!file3.trace?.sidecar);
162
+ t('sidecar path has expected shape', /^traces\/.+\.trace\.json$/.test(file3.trace.sidecar));
163
+ const sidecar = await stat(join(TMP, file3.trace.sidecar));
164
+ t('sidecar file exists with non-zero size', sidecar.size > 0);
165
+
166
+ console.log('\n=== autoReport: policy lookup ===');
167
+
168
+ const a1 = await autoReport('validator-exhausted', { tool: 'refine_composition' }, ctx);
169
+ const file_a1 = JSON.parse(await readFile(a1.path, 'utf8'));
170
+ t('validator-exhausted: type=bug', file_a1.type === 'bug');
171
+ t('validator-exhausted: severity=blocker', file_a1.severity === 'blocker');
172
+ t('validator-exhausted: suggested_owner=validator', file_a1.suggested_owner === 'validator');
173
+ t('validator-exhausted: reporter.kind=auto', file_a1.reporter.kind === 'auto');
174
+ t('validator-exhausted: reporter.context=validator-exhausted', file_a1.reporter.context === 'validator-exhausted');
175
+ t('validator-exhausted: tags include "auto-fire"', file_a1.tags.includes('auto-fire'));
176
+
177
+ const a2 = await autoReport('retrieval-zero-then-synthesis-fail', { intent: 'pricing page' }, ctx);
178
+ const file_a2 = JSON.parse(await readFile(a2.path, 'utf8'));
179
+ t('retrieval-zero-then-synthesis-fail: type=training-gap', file_a2.type === 'training-gap');
180
+ t('retrieval-zero-then-synthesis-fail: suggested_owner=chunk-corpus', file_a2.suggested_owner === 'chunk-corpus');
181
+ t('retrieval-zero-then-synthesis-fail: title carries intent', /pricing page/.test(file_a2.title));
182
+
183
+ const a3 = await autoReport('cache-miss-on-known-state', { state_id: 'gone-12ab-v1-0' }, ctx);
184
+ const file_a3 = JSON.parse(await readFile(a3.path, 'utf8'));
185
+ t('cache-miss-on-known-state: severity=nit', file_a3.severity === 'nit');
186
+
187
+ threw = false;
188
+ try { await autoReport('unknown-reason', {}, ctx); }
189
+ catch (e) { threw = /unknown reason/.test(e.message); }
190
+ t('autoReport rejects unknown reason', threw);
191
+
192
+ console.log('\n=== evalMode suppresses auto-fire ===');
193
+
194
+ const evalCtx = { ...ctx, evalMode: true };
195
+ const aSuppressed = await autoReport('validator-exhausted', { tool: 'refine_composition' }, evalCtx);
196
+ t('autoReport returns null when evalMode=true', aSuppressed === null);
197
+
198
+ // Manual reportIssue still writes during evalMode (eval-suppression is auto-fire only)
199
+ const aManual = await reportIssue({
200
+ type: 'bug',
201
+ severity: 'blocker',
202
+ title: 'Manual call during evalMode',
203
+ body: 'should still write',
204
+ }, evalCtx);
205
+ t('manual reportIssue ignores evalMode', !!aManual.issue_id);
206
+ const file_manual = JSON.parse(await readFile(aManual.path, 'utf8'));
207
+ t('manual call during evalMode writes file', file_manual.title === 'Manual call during evalMode');
208
+
209
+ console.log('\n=== coalescing accumulator ===');
210
+
211
+ const acc = createIssueAccumulator();
212
+ t('empty accumulator size 0', acc.size() === 0);
213
+ const flushEmpty = await acc.flush(ctx);
214
+ t('empty accumulator flush returns null', flushEmpty === null);
215
+
216
+ acc.add('locator-empty-targets', { intent: 'change title' });
217
+ t('single-entry accumulator size 1', acc.size() === 1);
218
+ const flushOne = await acc.flush(ctx);
219
+ const file_flush_one = JSON.parse(await readFile(flushOne.path, 'utf8'));
220
+ t('single-entry flush writes normal auto-issue', file_flush_one.reporter.kind === 'auto' && file_flush_one.reporter.context === 'locator-empty-targets');
221
+ t('single-entry flush resets accumulator', acc.size() === 0);
222
+
223
+ acc.add('locator-empty-targets', { intent: 'change title' });
224
+ acc.add('validator-exhausted', { tool: 'refine_composition' });
225
+ acc.add('ops-failed-after-apply', {});
226
+ t('three-entry accumulator size 3', acc.size() === 3);
227
+ const flushThree = await acc.flush(ctx);
228
+ const file_flush_three = JSON.parse(await readFile(flushThree.path, 'utf8'));
229
+ t('coalesced issue: severity=blocker (highest of three)', file_flush_three.severity === 'blocker');
230
+ t('coalesced issue: type=bug', file_flush_three.type === 'bug');
231
+ t('coalesced issue: reporter.context=coalesced', file_flush_three.reporter.context === 'coalesced');
232
+ t('coalesced issue: tags include "coalesced"', file_flush_three.tags.includes('coalesced'));
233
+ t('coalesced issue: tags include all reasons',
234
+ ['locator-empty-targets', 'validator-exhausted', 'ops-failed-after-apply'].every((r) => file_flush_three.tags.includes(r))
235
+ );
236
+ t('coalesced issue: body lists every reason',
237
+ file_flush_three.body.includes('locator-empty-targets') &&
238
+ file_flush_three.body.includes('validator-exhausted') &&
239
+ file_flush_three.body.includes('ops-failed-after-apply')
240
+ );
241
+ t('coalesced flush resets accumulator', acc.size() === 0);
242
+
243
+ // evalMode + coalesce → no write
244
+ const accEval = createIssueAccumulator();
245
+ accEval.add('validator-exhausted', {});
246
+ accEval.add('ops-failed-after-apply', {});
247
+ const flushEval = await accEval.flush({ ...ctx, evalMode: true });
248
+ t('coalesce flush returns null when evalMode=true', flushEval === null);
249
+
250
+ threw = false;
251
+ try { acc.add('not-a-real-reason'); }
252
+ catch (e) { threw = /unknown reason/.test(e.message); }
253
+ t('accumulator.add rejects unknown reason', threw);
254
+
255
+ console.log('\n=== AUTO_FIRE_POLICY exported ===');
256
+
257
+ t('AUTO_FIRE_POLICY exports expected reasons',
258
+ ['synthesizer-exhausted', 'validator-exhausted', 'locator-empty-targets',
259
+ 'retrieval-zero-then-synthesis-fail', 'cache-miss-on-known-state',
260
+ 'ops-failed-after-apply'].every((r) => AUTO_FIRE_POLICY[r])
261
+ );
262
+
263
+ await rm(TMP, { recursive: true, force: true });
264
+
265
+ console.log(`\n${pass} passed, ${fail} failed`);
266
+ process.exit(fail ? 1 : 0);
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ // Smoke test: chunk-refiner (validator + applier + 2-pass synthesis with stub LLM).
3
+ // Spec: docs/specs/genui-multiturn-architecture.md §4.
4
+
5
+ import {
6
+ refineFromIntent,
7
+ applyOps,
8
+ validateOps,
9
+ opsToA2UI,
10
+ } from '../../compose/engines/zettel/chunk-refiner.js';
11
+ import {
12
+ StateCache,
13
+ mintStateId,
14
+ mintNextStateId,
15
+ } from '../../compose/engines/zettel/state-cache.js';
16
+ import { createIssueAccumulator } from '../../compose/engines/zettel/issue-reporter.js';
17
+ import {
18
+ getChunk,
19
+ listChunksByKind,
20
+ } from '../../../a2ui/corpus/scripts/chunk-library.js';
21
+
22
+ let pass = 0, fail = 0;
23
+ const t = (label, ok, detail = '') => {
24
+ if (ok) { console.log(` ✓ ${label}`); pass++; }
25
+ else { console.log(` ✗ ${label} ${detail}`); fail++; }
26
+ };
27
+
28
+ // --- Discover real corpus chunks for happy-path tests ---
29
+ const pageChunks = listChunksByKind('page');
30
+ const panelChunks = listChunksByKind('panel');
31
+ const blockChunks = listChunksByKind('block');
32
+
33
+ if (pageChunks.length === 0 || blockChunks.length < 3) {
34
+ console.error(`Corpus missing required chunks (pages=${pageChunks.length}, blocks=${blockChunks.length}). Aborting.`);
35
+ process.exit(2);
36
+ }
37
+
38
+ // Pick a page chunk that actually has slots (not all do)
39
+ const samplePage = pageChunks.find((p) => (p.slots || p.instances?.[0]?.slots || []).length >= 1)
40
+ || panelChunks.find((p) => (p.slots || p.instances?.[0]?.slots || []).length >= 1)
41
+ || pageChunks[0];
42
+ const samplePageSlots = (samplePage.slots || samplePage.instances?.[0]?.slots || []).map((s) => s.name);
43
+
44
+ if (samplePageSlots.length === 0) {
45
+ console.error('No page chunk with declared slots; skipping LLM-driven tests.');
46
+ process.exit(2);
47
+ }
48
+
49
+ const sampleBlocks = blockChunks.slice(0, 5).map((b) => b.name);
50
+ const targetedSlot = samplePageSlots[0];
51
+
52
+ console.log(`Using page: ${samplePage.name} (slots: ${samplePageSlots.join(', ')})`);
53
+ console.log(`Sample blocks: ${sampleBlocks.slice(0, 3).join(', ')}\n`);
54
+
55
+ const priorState = {
56
+ state_id: mintStateId('test-prior', 1),
57
+ intent: 'test prior composition',
58
+ plan: {
59
+ page: samplePage.name,
60
+ slot_bindings: {
61
+ [targetedSlot]: [sampleBlocks[0]],
62
+ ...(samplePageSlots[1] ? { [samplePageSlots[1]]: [sampleBlocks[1], sampleBlocks[2]] } : {}),
63
+ },
64
+ },
65
+ html: '<placeholder/>',
66
+ };
67
+
68
+ console.log('=== validateOps ===');
69
+
70
+ t('rejects non-array ops',
71
+ validateOps('not-array', priorState).ok === false);
72
+ t('rejects priorState without plan',
73
+ validateOps([], { state_id: 'x' }).ok === false);
74
+ t('accepts empty ops list', validateOps([], priorState).ok === true);
75
+
76
+ const okRebind = validateOps(
77
+ [{ type: 'rebindSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] }],
78
+ priorState,
79
+ );
80
+ t('accepts rebindSlot with valid chunk', okRebind.ok === true);
81
+
82
+ const okAppend = validateOps(
83
+ [{ type: 'appendToSlot', slot: targetedSlot, chunks: [sampleBlocks[3], sampleBlocks[4]] }],
84
+ priorState,
85
+ );
86
+ t('accepts appendToSlot with valid chunks', okAppend.ok === true);
87
+
88
+ const okRemove = validateOps(
89
+ [{ type: 'removeFromSlot', slot: targetedSlot, indices: [0] }],
90
+ priorState,
91
+ );
92
+ t('accepts removeFromSlot with valid index', okRemove.ok === true);
93
+
94
+ // Negative cases
95
+ const badType = validateOps([{ type: 'frobnicate', slot: targetedSlot }], priorState);
96
+ t('rejects unknown op type',
97
+ badType.ok === false && /unknown op type/.test(badType.errors[0]));
98
+
99
+ const badSlot = validateOps(
100
+ [{ type: 'rebindSlot', slot: 'not-a-real-slot', chunks: [sampleBlocks[0]] }],
101
+ priorState,
102
+ );
103
+ t('rejects unknown slot',
104
+ badSlot.ok === false && badSlot.errors.some((e) => /not in prior plan/.test(e)));
105
+
106
+ const badChunk = validateOps(
107
+ [{ type: 'rebindSlot', slot: targetedSlot, chunks: ['not-a-real-chunk-xyz'] }],
108
+ priorState,
109
+ );
110
+ t('rejects unknown chunk reference',
111
+ badChunk.ok === false && badChunk.errors.some((e) => /not found/.test(e)));
112
+
113
+ const badIndex = validateOps(
114
+ [{ type: 'removeFromSlot', slot: targetedSlot, indices: [99] }],
115
+ priorState,
116
+ );
117
+ t('rejects out-of-range index',
118
+ badIndex.ok === false && badIndex.errors.some((e) => /out of range/.test(e)));
119
+
120
+ const emptyChunks = validateOps(
121
+ [{ type: 'rebindSlot', slot: targetedSlot, chunks: [] }],
122
+ priorState,
123
+ );
124
+ t('rejects empty chunks array',
125
+ emptyChunks.ok === false && emptyChunks.errors.some((e) => /non-empty/.test(e)));
126
+
127
+ const replacePageOk = validateOps(
128
+ [{ type: 'replacePage', page: samplePage.name, slot_bindings: {} }],
129
+ priorState,
130
+ );
131
+ t('accepts replacePage with valid page', replacePageOk.ok === true);
132
+
133
+ const replacePageBad = validateOps(
134
+ [{ type: 'replacePage', page: 'no-such-page' }],
135
+ priorState,
136
+ );
137
+ t('rejects replacePage with unknown page',
138
+ replacePageBad.ok === false && replacePageBad.errors.some((e) => /not found/.test(e)));
139
+
140
+ console.log('\n=== applyOps ===');
141
+
142
+ const applyRebind = await applyOps({
143
+ priorState,
144
+ ops: [{ type: 'rebindSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] }],
145
+ });
146
+ t('rebindSlot replaces slot bindings',
147
+ applyRebind.newState.plan.slot_bindings[targetedSlot].length === 1 &&
148
+ applyRebind.newState.plan.slot_bindings[targetedSlot][0] === sampleBlocks[3]);
149
+ t('rebindSlot returns op in ops_applied', applyRebind.ops_applied.length === 1);
150
+ t('rebindSlot returns empty ops_failed', applyRebind.ops_failed.length === 0);
151
+
152
+ const applyAppend = await applyOps({
153
+ priorState,
154
+ ops: [{ type: 'appendToSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] }],
155
+ });
156
+ t('appendToSlot adds to existing bindings',
157
+ applyAppend.newState.plan.slot_bindings[targetedSlot].length === 2 &&
158
+ applyAppend.newState.plan.slot_bindings[targetedSlot].at(-1) === sampleBlocks[3]);
159
+
160
+ const applyRemove = await applyOps({
161
+ priorState,
162
+ ops: [{ type: 'removeFromSlot', slot: targetedSlot, indices: [0] }],
163
+ });
164
+ t('removeFromSlot drops indexed entry',
165
+ applyRemove.newState.plan.slot_bindings[targetedSlot].length === 0);
166
+
167
+ const applyReplace = await applyOps({
168
+ priorState,
169
+ ops: [{
170
+ type: 'replacePage',
171
+ page: samplePage.name,
172
+ slot_bindings: { [targetedSlot]: [sampleBlocks[4]] },
173
+ }],
174
+ });
175
+ t('replacePage swaps page reference', applyReplace.newState.plan.page === samplePage.name);
176
+ t('replacePage replaces slot_bindings entirely',
177
+ applyReplace.newState.plan.slot_bindings[targetedSlot].length === 1);
178
+
179
+ const applyMixed = await applyOps({
180
+ priorState,
181
+ ops: [
182
+ { type: 'rebindSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] },
183
+ { type: 'appendToSlot', slot: targetedSlot, chunks: [sampleBlocks[4]] },
184
+ ],
185
+ });
186
+ t('multiple ops apply in sequence',
187
+ applyMixed.newState.plan.slot_bindings[targetedSlot].length === 2 &&
188
+ applyMixed.newState.plan.slot_bindings[targetedSlot][0] === sampleBlocks[3] &&
189
+ applyMixed.newState.plan.slot_bindings[targetedSlot][1] === sampleBlocks[4]);
190
+
191
+ const applyOriginalUntouched = priorState.plan.slot_bindings[targetedSlot];
192
+ t('priorState plan not mutated by applyOps',
193
+ Array.isArray(applyOriginalUntouched) && applyOriginalUntouched[0] === sampleBlocks[0]);
194
+
195
+ const applyMixedFail = await applyOps({
196
+ priorState,
197
+ ops: [
198
+ { type: 'rebindSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] },
199
+ { type: 'frobnicate' },
200
+ ],
201
+ });
202
+ t('apply continues past failed ops',
203
+ applyMixedFail.ops_applied.length === 1 && applyMixedFail.ops_failed.length === 1);
204
+
205
+ console.log('\n=== opsToA2UI ===');
206
+
207
+ const wireMessages = opsToA2UI(
208
+ [{ type: 'rebindSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] }],
209
+ applyRebind.newState,
210
+ );
211
+ t('opsToA2UI emits one updateComponents per op',
212
+ wireMessages.length === 1 && wireMessages[0].type === 'updateComponents');
213
+ t('opsToA2UI components target slot id', wireMessages[0].components[0].id === `slot-${targetedSlot}`);
214
+ t('opsToA2UI carries chunk_op echo', wireMessages[0].components[0].chunk_op?.type === 'rebindSlot');
215
+ t('opsToA2UI sets surfaceId', wireMessages[0].surfaceId === 'main');
216
+
217
+ const wireReplace = opsToA2UI(
218
+ [{ type: 'replacePage', page: samplePage.name, slot_bindings: {} }],
219
+ applyReplace.newState,
220
+ );
221
+ t('opsToA2UI replacePage targets surface id', wireReplace[0].components[0].id === 'main');
222
+
223
+ console.log('\n=== refineFromIntent (stub LLM) ===');
224
+
225
+ function makeStubLLM(queue) {
226
+ const responses = [...queue];
227
+ return {
228
+ complete: async () => {
229
+ if (responses.length === 0) throw new Error('stub LLM: queue empty');
230
+ const r = responses.shift();
231
+ return { content: typeof r === 'string' ? r : JSON.stringify(r) };
232
+ },
233
+ remaining: () => responses.length,
234
+ };
235
+ }
236
+
237
+ // Happy path — targeted refinement
238
+ const stubHappy = makeStubLLM([
239
+ { targeted: true, target_slots: [targetedSlot] },
240
+ {
241
+ ops: [{ type: 'rebindSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] }],
242
+ delta_summary: `replaced ${targetedSlot} binding`,
243
+ },
244
+ ]);
245
+ const refineHappy = await refineFromIntent({
246
+ priorState,
247
+ intent: `change ${targetedSlot} to use ${sampleBlocks[3]}`,
248
+ llmAdapter: stubHappy,
249
+ catalog: [{ name: sampleBlocks[3], kind: 'block', primary: 'div', slots: [] }],
250
+ });
251
+ t('happy-path: returns ops', refineHappy.ops.length === 1);
252
+ t('happy-path: synthesis.targeted=true', refineHappy.synthesis.targeted === true);
253
+ t('happy-path: synthesis.locatedTargets matches', refineHappy.synthesis.locatedTargets[0] === targetedSlot);
254
+ t('happy-path: synthesis.attempts=1', refineHappy.synthesis.attempts === 1);
255
+ t('happy-path: delta_summary set', !!refineHappy.delta_summary);
256
+ t('happy-path: empty warnings', refineHappy.warnings.length === 0);
257
+ t('happy-path: stub LLM exhausted', stubHappy.remaining() === 0);
258
+
259
+ // Validator-driven retry — first response invalid, second valid
260
+ const stubRetry = makeStubLLM([
261
+ { targeted: true, target_slots: [targetedSlot] },
262
+ { ops: [{ type: 'rebindSlot', slot: 'not-real', chunks: [sampleBlocks[3]] }], delta_summary: 'bad' },
263
+ { ops: [{ type: 'rebindSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] }], delta_summary: 'fixed' },
264
+ ]);
265
+ const refineRetry = await refineFromIntent({
266
+ priorState,
267
+ intent: `change ${targetedSlot}`,
268
+ llmAdapter: stubRetry,
269
+ maxAttempts: 2,
270
+ catalog: [{ name: sampleBlocks[3], kind: 'block', primary: 'div', slots: [] }],
271
+ });
272
+ t('retry: succeeds on second attempt', refineRetry.ops.length === 1);
273
+ t('retry: synthesis.attempts=2', refineRetry.synthesis.attempts === 2);
274
+ t('retry: attemptsLog has both attempts', refineRetry.synthesis.attemptsLog.length === 2);
275
+
276
+ // Validator-exhausted — both attempts invalid
277
+ const accExhausted = createIssueAccumulator();
278
+ const stubExhaust = makeStubLLM([
279
+ { targeted: true, target_slots: [targetedSlot] },
280
+ { ops: [{ type: 'rebindSlot', slot: 'not-real-1', chunks: [sampleBlocks[3]] }], delta_summary: 'bad1' },
281
+ { ops: [{ type: 'rebindSlot', slot: 'not-real-2', chunks: [sampleBlocks[3]] }], delta_summary: 'bad2' },
282
+ ]);
283
+ const refineExhaust = await refineFromIntent({
284
+ priorState,
285
+ intent: `change ${targetedSlot}`,
286
+ llmAdapter: stubExhaust,
287
+ maxAttempts: 2,
288
+ issueAccumulator: accExhausted,
289
+ catalog: [{ name: sampleBlocks[3], kind: 'block', primary: 'div', slots: [] }],
290
+ });
291
+ t('exhausted: returns empty ops', refineExhaust.ops.length === 0);
292
+ t('exhausted: warnings include "failed after 2 attempts"',
293
+ refineExhaust.warnings[0].includes('failed after 2 attempts'));
294
+ t('exhausted: auto-fires "validator-exhausted"',
295
+ accExhausted.reasons().includes('validator-exhausted'));
296
+
297
+ // locator-empty-targets: targeted=true but no slots returned
298
+ const accEmpty = createIssueAccumulator();
299
+ const stubEmpty = makeStubLLM([
300
+ { targeted: true, target_slots: [] },
301
+ { ops: [], delta_summary: 'no-op' },
302
+ ]);
303
+ await refineFromIntent({
304
+ priorState,
305
+ intent: 'change something specific',
306
+ llmAdapter: stubEmpty,
307
+ issueAccumulator: accEmpty,
308
+ catalog: [],
309
+ });
310
+ t('locator-empty: auto-fires "locator-empty-targets"',
311
+ accEmpty.reasons().includes('locator-empty-targets'));
312
+
313
+ // Untargeted intent — no auto-fire on locator-empty
314
+ const accUntargeted = createIssueAccumulator();
315
+ const stubUntargeted = makeStubLLM([
316
+ { targeted: false, target_slots: [] },
317
+ { ops: [], delta_summary: 'no-op' },
318
+ ]);
319
+ await refineFromIntent({
320
+ priorState,
321
+ intent: 'preserve everything',
322
+ llmAdapter: stubUntargeted,
323
+ issueAccumulator: accUntargeted,
324
+ catalog: [],
325
+ });
326
+ t('untargeted: no auto-fire on locator-empty',
327
+ !accUntargeted.reasons().includes('locator-empty-targets'));
328
+
329
+ // Missing llmAdapter — graceful fail
330
+ const refineNoLLM = await refineFromIntent({
331
+ priorState,
332
+ intent: 'anything',
333
+ llmAdapter: null,
334
+ });
335
+ t('no llmAdapter: returns empty ops', refineNoLLM.ops.length === 0);
336
+ t('no llmAdapter: warnings include "no llmAdapter"',
337
+ refineNoLLM.warnings[0].includes('no llmAdapter'));
338
+
339
+ // Missing priorState plan — graceful fail
340
+ const refineNoPlan = await refineFromIntent({
341
+ priorState: { state_id: 'x' },
342
+ intent: 'anything',
343
+ llmAdapter: makeStubLLM([{ targeted: false, target_slots: [] }]),
344
+ });
345
+ t('no priorState.plan: returns empty ops', refineNoPlan.ops.length === 0);
346
+ t('no priorState.plan: warnings flag the issue',
347
+ refineNoPlan.warnings[0].includes('no plan'));
348
+
349
+ console.log('\n=== integration: refine → apply → wire ===');
350
+
351
+ const stubE2E = makeStubLLM([
352
+ { targeted: true, target_slots: [targetedSlot] },
353
+ {
354
+ ops: [{ type: 'appendToSlot', slot: targetedSlot, chunks: [sampleBlocks[3]] }],
355
+ delta_summary: `appended ${sampleBlocks[3]} to ${targetedSlot}`,
356
+ },
357
+ ]);
358
+ const refineE2E = await refineFromIntent({
359
+ priorState,
360
+ intent: `add ${sampleBlocks[3]} to ${targetedSlot}`,
361
+ llmAdapter: stubE2E,
362
+ catalog: [{ name: sampleBlocks[3], kind: 'block', primary: 'div', slots: [] }],
363
+ });
364
+ const applied = await applyOps({ priorState, ops: refineE2E.ops });
365
+ const a2uiMsgs = opsToA2UI(refineE2E.ops, applied.newState);
366
+
367
+ t('e2e: refine produces 1 op', refineE2E.ops.length === 1);
368
+ t('e2e: apply succeeds', applied.ops_applied.length === 1 && applied.ops_failed.length === 0);
369
+ t('e2e: a2ui message has updateComponents type', a2uiMsgs[0].type === 'updateComponents');
370
+ t('e2e: applied newState has materialized HTML',
371
+ applied.newState.html !== null && applied.newState.html.length > 0);
372
+
373
+ console.log(`\n${pass} passed, ${fail} failed`);
374
+ process.exit(fail ? 1 : 0);