@openwop/openwop-conformance 1.37.0 → 1.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +32 -0
- package/README.md +2 -2
- package/api/openapi.yaml +62 -5
- package/fixtures/conformance-agent-memory-injection-budget.json +44 -0
- package/fixtures/conformance-context-budget-multiturn.json +50 -0
- package/fixtures.md +2 -0
- package/package.json +1 -1
- package/schemas/README.md +3 -0
- package/schemas/a2ui-surface-delta-frame.schema.json +48 -0
- package/schemas/capabilities.schema.json +128 -1
- package/schemas/channel-presence-payload.schema.json +41 -0
- package/schemas/compact-tool-descriptor.schema.json +51 -0
- package/schemas/conversation-turn.schema.json +10 -0
- package/schemas/memory-list-options.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +25 -2
- package/schemas/run-event.schema.json +2 -0
- package/src/lib/toolCatalog.ts +89 -0
- package/src/scenarios/a2ui-surface-delta-transport.test.ts +600 -0
- package/src/scenarios/channel-presence-behavioral.test.ts +83 -0
- package/src/scenarios/channel-presence-shape.test.ts +93 -0
- package/src/scenarios/context-budget-transcript-bound.test.ts +253 -0
- package/src/scenarios/context-summarization-replay.test.ts +155 -0
- package/src/scenarios/conversation-turn-model-provenance-shape.test.ts +120 -0
- package/src/scenarios/memory-injection-budget.test.ts +188 -0
- package/src/scenarios/prompt-prefix-cache.test.ts +200 -0
- package/src/scenarios/run-transport-economy.test.ts +236 -0
- package/src/scenarios/tool-catalog-compact-projection.test.ts +149 -0
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a2ui-surface-delta-transport — RFC 0114 §"Delta transport".
|
|
3
|
+
*
|
|
4
|
+
* RFC 0114 adds an opt-in, HOST-SIDE TRANSPORT projection over the recorded
|
|
5
|
+
* `ui.a2ui-surface` envelope (RFC 0102): the recorded envelope stays the FULL
|
|
6
|
+
* surface (replay-pinned, security-validated, event-log-full — UNCHANGED), and
|
|
7
|
+
* a host that advertises `a2uiSurface.deltaTransport: true` MAY deliver RFC 6902
|
|
8
|
+
* (JSON-Patch) delta frames over the run event stream to subscribers that
|
|
9
|
+
* negotiate `?a2uiDelta=1`, materializing the full surface for everyone else.
|
|
10
|
+
*
|
|
11
|
+
* Assertions:
|
|
12
|
+
*
|
|
13
|
+
* Always-on (server-free, Ajv2020 + a client-side RFC 6902 applier):
|
|
14
|
+
* 1. `schemas/a2ui-surface-delta-frame.schema.json` compiles; a positive
|
|
15
|
+
* frame validates; the `test` op is EXCLUDED from the op enum.
|
|
16
|
+
* 2. A full surface + a sequence of delta frames reconstruct the expected
|
|
17
|
+
* tree (apply RFC 6902 client-side) AND the reconstruction equals the
|
|
18
|
+
* full surface a non-negotiating subscriber materializes (delta and full
|
|
19
|
+
* agree); the reconstruction validates against the closed catalog.
|
|
20
|
+
* 3. A delta whose `patch` `add`s an OUT-OF-CATALOG component yields a
|
|
21
|
+
* post-patch surface that FAILS closed-catalog validation → rejected
|
|
22
|
+
* fail-closed (the `a2ui-surface-no-code-exec` boundary holds post-patch),
|
|
23
|
+
* forcing full re-materialization.
|
|
24
|
+
* 4. The recorded envelope (event-log read / replay) is ALWAYS the full
|
|
25
|
+
* surface, never a delta: a delta frame does NOT validate against the
|
|
26
|
+
* recorded `ui.a2ui-surface` envelope schema, and the full surface does.
|
|
27
|
+
* 5. `catalogVersion` on a delta frame MUST equal the referenced full
|
|
28
|
+
* surface's.
|
|
29
|
+
*
|
|
30
|
+
* Capability-gated (HTTP, soft-skip on absent host / absent capability):
|
|
31
|
+
* 6. When the host advertises `a2uiSurface.deltaTransport`, a non-negotiating
|
|
32
|
+
* events subscriber (no `?a2uiDelta=1`) receives a FULL `ui.a2ui-surface`
|
|
33
|
+
* surface, never a delta frame — the default-materialization floor.
|
|
34
|
+
*
|
|
35
|
+
* @see RFCS/0114-a2ui-surface-deltas.md
|
|
36
|
+
* @see spec/v1/ai-envelope.md §"Delta transport"
|
|
37
|
+
* @see spec/v1/stream-modes.md §"A2UI delta transport"
|
|
38
|
+
* @see schemas/a2ui-surface-delta-frame.schema.json
|
|
39
|
+
* @see SECURITY/invariants.yaml (a2ui-surface-no-code-exec)
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { describe, it, expect } from 'vitest';
|
|
43
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
44
|
+
import type { ValidateFunction } from 'ajv';
|
|
45
|
+
import { readFileSync } from 'node:fs';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
48
|
+
import { driver } from '../lib/driver.js';
|
|
49
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
50
|
+
|
|
51
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
52
|
+
|
|
53
|
+
const recordedEnvelopeSchema = JSON.parse(
|
|
54
|
+
readFileSync(join(SCHEMAS_DIR, 'envelopes/ui.a2ui-surface.schema.json'), 'utf8'),
|
|
55
|
+
) as Record<string, unknown>;
|
|
56
|
+
const deltaFrameSchema = JSON.parse(
|
|
57
|
+
readFileSync(join(SCHEMAS_DIR, 'a2ui-surface-delta-frame.schema.json'), 'utf8'),
|
|
58
|
+
) as Record<string, unknown>;
|
|
59
|
+
|
|
60
|
+
const validateRecorded: ValidateFunction = ajv.compile(recordedEnvelopeSchema);
|
|
61
|
+
const validateFrame: ValidateFunction = ajv.compile(deltaFrameSchema);
|
|
62
|
+
|
|
63
|
+
// ── Minimal, typed JSON value model + RFC 6901 / RFC 6902 applier ────────────
|
|
64
|
+
// A cast-free reference applier: enough of RFC 6902 to exercise the transport
|
|
65
|
+
// contract (add/remove/replace over RFC 6901 pointers). The suite owns this so
|
|
66
|
+
// the scenario carries no external JSON-Patch dependency.
|
|
67
|
+
|
|
68
|
+
type JsonValue =
|
|
69
|
+
| null
|
|
70
|
+
| boolean
|
|
71
|
+
| number
|
|
72
|
+
| string
|
|
73
|
+
| JsonValue[]
|
|
74
|
+
| { [key: string]: JsonValue };
|
|
75
|
+
|
|
76
|
+
interface PatchOp {
|
|
77
|
+
readonly op: 'add' | 'remove' | 'replace' | 'move' | 'copy';
|
|
78
|
+
readonly path: string;
|
|
79
|
+
readonly from?: string;
|
|
80
|
+
readonly value?: JsonValue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRecord(v: JsonValue): v is { [key: string]: JsonValue } {
|
|
84
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clone(v: JsonValue): JsonValue {
|
|
88
|
+
return JSON.parse(JSON.stringify(v)) as JsonValue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function unescapeToken(token: string): string {
|
|
92
|
+
return token.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parsePointer(pointer: string): string[] {
|
|
96
|
+
if (pointer === '') return [];
|
|
97
|
+
if (!pointer.startsWith('/')) throw new Error(`invalid JSON pointer: ${pointer}`);
|
|
98
|
+
return pointer.slice(1).split('/').map(unescapeToken);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Read the value at `pointer`; throws on any missing/invalid segment. */
|
|
102
|
+
function getAt(doc: JsonValue, pointer: string): JsonValue {
|
|
103
|
+
let node: JsonValue = doc;
|
|
104
|
+
for (const token of parsePointer(pointer)) {
|
|
105
|
+
if (Array.isArray(node)) {
|
|
106
|
+
const idx = Number(token);
|
|
107
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= node.length) {
|
|
108
|
+
throw new Error(`pointer index out of range: ${pointer}`);
|
|
109
|
+
}
|
|
110
|
+
node = node[idx];
|
|
111
|
+
} else if (isRecord(node)) {
|
|
112
|
+
if (!Object.prototype.hasOwnProperty.call(node, token)) {
|
|
113
|
+
throw new Error(`pointer key missing: ${pointer}`);
|
|
114
|
+
}
|
|
115
|
+
node = node[token];
|
|
116
|
+
} else {
|
|
117
|
+
throw new Error(`pointer descends into a scalar: ${pointer}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return node;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function setAt(doc: JsonValue, pointer: string, value: JsonValue, mode: 'add' | 'replace'): void {
|
|
124
|
+
const tokens = parsePointer(pointer);
|
|
125
|
+
if (tokens.length === 0) throw new Error('cannot set the document root');
|
|
126
|
+
const last = tokens[tokens.length - 1];
|
|
127
|
+
let parent: JsonValue = doc;
|
|
128
|
+
for (const token of tokens.slice(0, -1)) {
|
|
129
|
+
parent = stepInto(parent, token, pointer);
|
|
130
|
+
}
|
|
131
|
+
if (Array.isArray(parent)) {
|
|
132
|
+
const idx = last === '-' ? parent.length : Number(last);
|
|
133
|
+
if (mode === 'add') {
|
|
134
|
+
if (last === '-') parent.push(value);
|
|
135
|
+
else {
|
|
136
|
+
if (!Number.isInteger(idx) || idx < 0 || idx > parent.length) {
|
|
137
|
+
throw new Error(`array add index out of range: ${pointer}`);
|
|
138
|
+
}
|
|
139
|
+
parent.splice(idx, 0, value);
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= parent.length) {
|
|
143
|
+
throw new Error(`array replace index out of range: ${pointer}`);
|
|
144
|
+
}
|
|
145
|
+
parent[idx] = value;
|
|
146
|
+
}
|
|
147
|
+
} else if (isRecord(parent)) {
|
|
148
|
+
if (mode === 'replace' && !Object.prototype.hasOwnProperty.call(parent, last)) {
|
|
149
|
+
throw new Error(`replace target missing: ${pointer}`);
|
|
150
|
+
}
|
|
151
|
+
parent[last] = value;
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`cannot set into a scalar parent: ${pointer}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function removeAt(doc: JsonValue, pointer: string): JsonValue {
|
|
158
|
+
const tokens = parsePointer(pointer);
|
|
159
|
+
if (tokens.length === 0) throw new Error('cannot remove the document root');
|
|
160
|
+
const last = tokens[tokens.length - 1];
|
|
161
|
+
let parent: JsonValue = doc;
|
|
162
|
+
for (const token of tokens.slice(0, -1)) {
|
|
163
|
+
parent = stepInto(parent, token, pointer);
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(parent)) {
|
|
166
|
+
const idx = Number(last);
|
|
167
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= parent.length) {
|
|
168
|
+
throw new Error(`array remove index out of range: ${pointer}`);
|
|
169
|
+
}
|
|
170
|
+
const [removed] = parent.splice(idx, 1);
|
|
171
|
+
return removed;
|
|
172
|
+
}
|
|
173
|
+
if (isRecord(parent)) {
|
|
174
|
+
if (!Object.prototype.hasOwnProperty.call(parent, last)) {
|
|
175
|
+
throw new Error(`remove target missing: ${pointer}`);
|
|
176
|
+
}
|
|
177
|
+
const removed = parent[last];
|
|
178
|
+
delete parent[last];
|
|
179
|
+
return removed;
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`cannot remove from a scalar parent: ${pointer}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function stepInto(node: JsonValue, token: string, pointer: string): JsonValue {
|
|
185
|
+
if (Array.isArray(node)) {
|
|
186
|
+
const idx = Number(token);
|
|
187
|
+
if (!Number.isInteger(idx) || idx < 0 || idx >= node.length) {
|
|
188
|
+
throw new Error(`pointer index out of range: ${pointer}`);
|
|
189
|
+
}
|
|
190
|
+
return node[idx];
|
|
191
|
+
}
|
|
192
|
+
if (isRecord(node)) {
|
|
193
|
+
if (!Object.prototype.hasOwnProperty.call(node, token)) {
|
|
194
|
+
throw new Error(`pointer key missing: ${pointer}`);
|
|
195
|
+
}
|
|
196
|
+
return node[token];
|
|
197
|
+
}
|
|
198
|
+
throw new Error(`pointer descends into a scalar: ${pointer}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Apply an RFC 6902 patch to a deep clone of `doc`; throws on any failure. */
|
|
202
|
+
function applyPatch(doc: JsonValue, patch: readonly PatchOp[]): JsonValue {
|
|
203
|
+
const out = clone(doc);
|
|
204
|
+
for (const op of patch) {
|
|
205
|
+
switch (op.op) {
|
|
206
|
+
case 'add':
|
|
207
|
+
setAt(out, op.path, op.value ?? null, 'add');
|
|
208
|
+
break;
|
|
209
|
+
case 'replace':
|
|
210
|
+
setAt(out, op.path, op.value ?? null, 'replace');
|
|
211
|
+
break;
|
|
212
|
+
case 'remove':
|
|
213
|
+
removeAt(out, op.path);
|
|
214
|
+
break;
|
|
215
|
+
case 'move': {
|
|
216
|
+
if (op.from === undefined) throw new Error('move requires `from`');
|
|
217
|
+
const moved = removeAt(out, op.from);
|
|
218
|
+
setAt(out, op.path, moved, 'add');
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
case 'copy': {
|
|
222
|
+
if (op.from === undefined) throw new Error('copy requires `from`');
|
|
223
|
+
const copied = clone(getAt(out, op.from));
|
|
224
|
+
setAt(out, op.path, copied, 'add');
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
default:
|
|
228
|
+
throw new Error(`unsupported op: ${String(op.op)}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const CATALOG_VERSION = '0.9.1';
|
|
237
|
+
|
|
238
|
+
function fullSurfaceV0(): { [key: string]: JsonValue } {
|
|
239
|
+
return {
|
|
240
|
+
catalogVersion: CATALOG_VERSION,
|
|
241
|
+
surface: {
|
|
242
|
+
title: 'Schedule the kickoff',
|
|
243
|
+
components: [
|
|
244
|
+
{ component: 'heading', text: 'Kickoff', level: 2 },
|
|
245
|
+
{ component: 'text', text: 'Pick a date and confirm.' },
|
|
246
|
+
{ component: 'field.text', id: 'name', label: 'Your name', required: true },
|
|
247
|
+
{ component: 'action.button', id: 'go', label: 'Confirm', action: { target: 'resume' } },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The full surface a NON-NEGOTIATING subscriber materializes after the same two
|
|
254
|
+
// updates the delta frames carry — the independent oracle for "delta and full agree".
|
|
255
|
+
function fullSurfaceMaterializedAfterUpdates(): { [key: string]: JsonValue } {
|
|
256
|
+
return {
|
|
257
|
+
catalogVersion: CATALOG_VERSION,
|
|
258
|
+
surface: {
|
|
259
|
+
title: 'Schedule the kickoff',
|
|
260
|
+
components: [
|
|
261
|
+
{ component: 'heading', text: 'Kickoff', level: 2 },
|
|
262
|
+
{ component: 'text', text: 'Confirmed — see you then.' },
|
|
263
|
+
{ component: 'field.text', id: 'name', label: 'Your name', required: true },
|
|
264
|
+
{ component: 'action.button', id: 'go', label: 'Confirm', action: { target: 'resume' } },
|
|
265
|
+
{ component: 'text', text: 'A calendar invite is on its way.' },
|
|
266
|
+
],
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
describe('a2ui-surface-delta-transport: frame schema + op set (RFC 0114)', () => {
|
|
272
|
+
it('a2ui-surface-delta-frame.schema.json compiles under Ajv2020', () => {
|
|
273
|
+
expect(validateFrame, 'RFC 0114: the delta-frame schema MUST compile').toBeTypeOf('function');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('accepts a positive delta frame', () => {
|
|
277
|
+
const frame = {
|
|
278
|
+
surfaceRef: 'evt_9',
|
|
279
|
+
catalogVersion: CATALOG_VERSION,
|
|
280
|
+
patch: [{ op: 'replace', path: '/surface/components/1/text', value: 'Done' }],
|
|
281
|
+
};
|
|
282
|
+
expect(
|
|
283
|
+
validateFrame(frame),
|
|
284
|
+
`RFC 0114: a positive delta frame MUST validate; errors: ${JSON.stringify(validateFrame.errors)}`,
|
|
285
|
+
).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('rejects a frame missing the required `surfaceRef`/`catalogVersion`/`patch`', () => {
|
|
289
|
+
expect(validateFrame({ catalogVersion: CATALOG_VERSION, patch: [{ op: 'replace', path: '/x' }] })).toBe(false);
|
|
290
|
+
expect(validateFrame({ surfaceRef: 'e', patch: [{ op: 'replace', path: '/x' }] })).toBe(false);
|
|
291
|
+
expect(validateFrame({ surfaceRef: 'e', catalogVersion: CATALOG_VERSION })).toBe(false);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects an empty patch (minItems: 1)', () => {
|
|
295
|
+
expect(validateFrame({ surfaceRef: 'e', catalogVersion: CATALOG_VERSION, patch: [] })).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('EXCLUDES the `test` op from the op enum', () => {
|
|
299
|
+
const frame = {
|
|
300
|
+
surfaceRef: 'evt_9',
|
|
301
|
+
catalogVersion: CATALOG_VERSION,
|
|
302
|
+
patch: [{ op: 'test', path: '/surface/components/0/component', value: 'heading' }],
|
|
303
|
+
};
|
|
304
|
+
expect(
|
|
305
|
+
validateFrame(frame),
|
|
306
|
+
'RFC 0114: a fire-and-forget transport frame cannot act on a failed conditional; `test` is excluded',
|
|
307
|
+
).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('rejects a patch item carrying an out-of-set property (additionalProperties:false)', () => {
|
|
311
|
+
const frame = {
|
|
312
|
+
surfaceRef: 'evt_9',
|
|
313
|
+
catalogVersion: CATALOG_VERSION,
|
|
314
|
+
patch: [{ op: 'replace', path: '/x', value: 1, onApply: "fetch('https://evil')" }],
|
|
315
|
+
};
|
|
316
|
+
expect(validateFrame(frame)).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
describe('a2ui-surface-delta-transport: full + deltas reconstruct the materialized full (RFC 0114)', () => {
|
|
321
|
+
it('a full surface + delta frames reconstruct the tree AND equal the non-negotiating full', () => {
|
|
322
|
+
const recordedRef = 'evt_9';
|
|
323
|
+
const full = fullSurfaceV0();
|
|
324
|
+
|
|
325
|
+
// Frame 1: replace a leaf. Frame 2: append a new IN-CATALOG component.
|
|
326
|
+
const frames = [
|
|
327
|
+
{
|
|
328
|
+
surfaceRef: recordedRef,
|
|
329
|
+
catalogVersion: CATALOG_VERSION,
|
|
330
|
+
patch: [{ op: 'replace' as const, path: '/surface/components/1/text', value: 'Confirmed — see you then.' }],
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
surfaceRef: recordedRef,
|
|
334
|
+
catalogVersion: CATALOG_VERSION,
|
|
335
|
+
patch: [{ op: 'add' as const, path: '/surface/components/-', value: { component: 'text', text: 'A calendar invite is on its way.' } }],
|
|
336
|
+
},
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
for (const frame of frames) {
|
|
340
|
+
expect(validateFrame(frame), `RFC 0114: each delta frame MUST validate; ${JSON.stringify(validateFrame.errors)}`).toBe(true);
|
|
341
|
+
// catalogVersion on a delta MUST equal the referenced full surface's.
|
|
342
|
+
expect(
|
|
343
|
+
frame.catalogVersion,
|
|
344
|
+
'RFC 0114: a delta frame\'s catalogVersion MUST equal the referenced full surface\'s',
|
|
345
|
+
).toBe(full.catalogVersion);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Apply the frames client-side over the surface last delivered under surfaceRef.
|
|
349
|
+
let reconstructed: JsonValue = full;
|
|
350
|
+
for (const frame of frames) {
|
|
351
|
+
reconstructed = applyPatch(reconstructed, frame.patch);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// (a) the reconstruction equals the full surface a non-negotiating subscriber materializes.
|
|
355
|
+
expect(
|
|
356
|
+
reconstructed,
|
|
357
|
+
'RFC 0114: the delta reconstruction MUST equal the full surface the host materializes for a non-negotiating subscriber',
|
|
358
|
+
).toEqual(fullSurfaceMaterializedAfterUpdates());
|
|
359
|
+
|
|
360
|
+
// (b) the reconstruction re-validates against the closed catalog before render.
|
|
361
|
+
expect(
|
|
362
|
+
validateRecorded(reconstructed),
|
|
363
|
+
`RFC 0114: the post-patch surface MUST re-validate against the closed catalog; ${JSON.stringify(validateRecorded.errors)}`,
|
|
364
|
+
).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe('a2ui-surface-delta-transport: out-of-catalog delta fails closed (RFC 0114)', () => {
|
|
369
|
+
it('a delta that `add`s an out-of-catalog component yields a post-patch surface that FAILS closed-catalog validation', () => {
|
|
370
|
+
const full = fullSurfaceV0();
|
|
371
|
+
const outOfCatalogFrame = {
|
|
372
|
+
surfaceRef: 'evt_9',
|
|
373
|
+
catalogVersion: CATALOG_VERSION,
|
|
374
|
+
patch: [
|
|
375
|
+
{
|
|
376
|
+
op: 'add' as const,
|
|
377
|
+
path: '/surface/components/-',
|
|
378
|
+
value: { component: 'iframe', src: 'https://evil.example/x' },
|
|
379
|
+
},
|
|
380
|
+
],
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// The frame is structurally a valid delta FRAME (the catalog check is render-side).
|
|
384
|
+
expect(validateFrame(outOfCatalogFrame)).toBe(true);
|
|
385
|
+
|
|
386
|
+
const postPatch = applyPatch(full, outOfCatalogFrame.patch);
|
|
387
|
+
|
|
388
|
+
// The post-patch surface MUST fail the closed-catalog validation a full surface
|
|
389
|
+
// receives — the consumer rejects fail-closed and the host re-materializes full.
|
|
390
|
+
// The `a2ui-surface-no-code-exec` boundary holds on the post-patch surface.
|
|
391
|
+
expect(
|
|
392
|
+
validateRecorded(postPatch),
|
|
393
|
+
'RFC 0114 / a2ui-surface-no-code-exec: an out-of-catalog component reached by a delta MUST fail closed-catalog validation post-patch',
|
|
394
|
+
).toBe(false);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('a delta `replace` smuggling a script-bearing property also fails closed-catalog validation', () => {
|
|
398
|
+
const full = fullSurfaceV0();
|
|
399
|
+
const frame = {
|
|
400
|
+
surfaceRef: 'evt_9',
|
|
401
|
+
catalogVersion: CATALOG_VERSION,
|
|
402
|
+
patch: [
|
|
403
|
+
{
|
|
404
|
+
op: 'replace' as const,
|
|
405
|
+
path: '/surface/components/0',
|
|
406
|
+
value: { component: 'heading', text: 'Hi', onClick: "fetch('https://evil')" },
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
};
|
|
410
|
+
expect(validateFrame(frame)).toBe(true);
|
|
411
|
+
const postPatch = applyPatch(full, frame.patch);
|
|
412
|
+
expect(
|
|
413
|
+
validateRecorded(postPatch),
|
|
414
|
+
'RFC 0114: a delta MUST NOT be a path by which a smuggled code field reaches render',
|
|
415
|
+
).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('a2ui-surface-delta-transport: the recorded envelope is always full (RFC 0114)', () => {
|
|
420
|
+
it('a delta frame does NOT validate against the recorded ui.a2ui-surface envelope schema', () => {
|
|
421
|
+
const frame = {
|
|
422
|
+
surfaceRef: 'evt_9',
|
|
423
|
+
catalogVersion: CATALOG_VERSION,
|
|
424
|
+
patch: [{ op: 'replace', path: '/surface/components/1/text', value: 'Done' }],
|
|
425
|
+
};
|
|
426
|
+
expect(
|
|
427
|
+
validateRecorded(frame),
|
|
428
|
+
'RFC 0114: the recorded envelope is NEVER a delta — a delta frame MUST NOT validate as a recorded ui.a2ui-surface payload',
|
|
429
|
+
).toBe(false);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('the full surface validates as the recorded ui.a2ui-surface payload (event-log read / replay shape)', () => {
|
|
433
|
+
expect(
|
|
434
|
+
validateRecorded(fullSurfaceV0()),
|
|
435
|
+
'RFC 0114: the event-log read / replay always sees the FULL surface, which MUST validate as the recorded payload',
|
|
436
|
+
).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ── Capability-gated HTTP leg (soft-skip on absent host / absent capability) ──
|
|
441
|
+
//
|
|
442
|
+
// Non-vacuity (RFC 0114, host-sample-test-seams.md §15): a real `ui.a2ui-surface`
|
|
443
|
+
// envelope is one-shot per producing node, so the harness drives the SECOND
|
|
444
|
+
// surface emission through the OPTIONAL `POST /v1/host/sample/a2ui/emit-surface`
|
|
445
|
+
// seam — which MUST flow through the host's REAL surface-emit, the REAL
|
|
446
|
+
// `?a2uiDelta=1` transport, and the REAL closed-catalog validator. The harness
|
|
447
|
+
// only supplies the surface-update trigger (same shape as RFC 0115's
|
|
448
|
+
// harness-driven re-poll); the host produces the delta as it would in production.
|
|
449
|
+
|
|
450
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
451
|
+
|
|
452
|
+
interface A2uiSurfaceCap {
|
|
453
|
+
readonly deltaTransport?: boolean;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
interface EmitSurfaceResponse {
|
|
457
|
+
readonly surfaceRef?: string;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Recursively collect every object in `node` that validates against `check`. */
|
|
461
|
+
function collectMatching(node: unknown, check: ValidateFunction): JsonValue[] {
|
|
462
|
+
const out: JsonValue[] = [];
|
|
463
|
+
const visit = (n: unknown): void => {
|
|
464
|
+
if (n === null || typeof n !== 'object') return;
|
|
465
|
+
if (Array.isArray(n)) {
|
|
466
|
+
n.forEach(visit);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (check(n)) out.push(n as JsonValue);
|
|
470
|
+
Object.values(n as Record<string, unknown>).forEach(visit);
|
|
471
|
+
};
|
|
472
|
+
visit(node);
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Cast-free reader: narrow a validated JsonValue into a typed delta frame. */
|
|
477
|
+
function readFrame(v: JsonValue): { surfaceRef: string; catalogVersion: string; patch: PatchOp[] } | null {
|
|
478
|
+
if (!isRecord(v)) return null;
|
|
479
|
+
const { surfaceRef, catalogVersion, patch } = v;
|
|
480
|
+
if (typeof surfaceRef !== 'string' || typeof catalogVersion !== 'string' || !Array.isArray(patch)) return null;
|
|
481
|
+
const ops: PatchOp[] = [];
|
|
482
|
+
for (const item of patch) {
|
|
483
|
+
if (!isRecord(item)) return null;
|
|
484
|
+
const { op, path, from } = item;
|
|
485
|
+
if (typeof op !== 'string' || typeof path !== 'string') return null;
|
|
486
|
+
if (op !== 'add' && op !== 'remove' && op !== 'replace' && op !== 'move' && op !== 'copy') return null;
|
|
487
|
+
ops.push({
|
|
488
|
+
op,
|
|
489
|
+
path,
|
|
490
|
+
...(typeof from === 'string' ? { from } : {}),
|
|
491
|
+
...(Object.prototype.hasOwnProperty.call(item, 'value') ? { value: item.value } : {}),
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return { surfaceRef, catalogVersion, patch: ops };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** GET the run events as a JSON envelope; return the parsed body or null. */
|
|
498
|
+
async function getEvents(runId: string, deltaOptIn: boolean): Promise<unknown> {
|
|
499
|
+
const q = deltaOptIn ? '?a2uiDelta=1' : '';
|
|
500
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events${q}`, {
|
|
501
|
+
headers: { Accept: 'application/json' },
|
|
502
|
+
});
|
|
503
|
+
if (res.status !== 200) return null;
|
|
504
|
+
return res.json;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
describe.skipIf(HTTP_SKIP)('a2ui-surface-delta-transport: live host delta transport (RFC 0114, gated)', () => {
|
|
508
|
+
it('drives the emit-surface seam: a ?a2uiDelta=1 subscriber reconstruction equals the non-negotiating full', async () => {
|
|
509
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
510
|
+
if (disco.status !== 200) return; // no discovery — soft-skip
|
|
511
|
+
const cap = capabilityFamily<A2uiSurfaceCap>(disco.json, 'a2uiSurface');
|
|
512
|
+
if (cap?.deltaTransport !== true) return; // capability not advertised — soft-skip
|
|
513
|
+
|
|
514
|
+
const runId = `conf-a2ui-delta-${Date.now()}`;
|
|
515
|
+
const surfaceA = fullSurfaceV0();
|
|
516
|
+
const surfaceB = fullSurfaceMaterializedAfterUpdates();
|
|
517
|
+
|
|
518
|
+
// Baseline (surface A) through the host's REAL surface-emit path.
|
|
519
|
+
const emitA = await driver.post('/v1/host/sample/a2ui/emit-surface', { runId, surface: surfaceA });
|
|
520
|
+
if (emitA.status === 404 || emitA.status === 405) return; // seam absent — soft-skip the live leg
|
|
521
|
+
expect(
|
|
522
|
+
emitA.status,
|
|
523
|
+
driver.describe('RFC 0114 §15', 'the emit-surface seam MUST record the baseline full surface'),
|
|
524
|
+
).toBeLessThan(300);
|
|
525
|
+
|
|
526
|
+
// Second full surface (surface B) — recorded full AND transported as a delta
|
|
527
|
+
// to any ?a2uiDelta=1 subscriber.
|
|
528
|
+
const emitB = await driver.post('/v1/host/sample/a2ui/emit-surface', { runId, surface: surfaceB });
|
|
529
|
+
if (emitB.status === 404 || emitB.status === 405) return;
|
|
530
|
+
expect(emitB.status, driver.describe('RFC 0114 §15', 'the second emit MUST succeed')).toBeLessThan(300);
|
|
531
|
+
const refB = (emitB.json as EmitSurfaceResponse)?.surfaceRef;
|
|
532
|
+
|
|
533
|
+
// ?a2uiDelta=1 subscriber: locate the delta frame the host transported.
|
|
534
|
+
const deltaEvents = await getEvents(runId, true);
|
|
535
|
+
if (deltaEvents === null) return; // events stream unavailable in JSON — soft-skip
|
|
536
|
+
const frames = collectMatching(deltaEvents, validateFrame)
|
|
537
|
+
.map(readFrame)
|
|
538
|
+
.filter((f): f is { surfaceRef: string; catalogVersion: string; patch: PatchOp[] } => f !== null)
|
|
539
|
+
.filter((f) => refB === undefined || f.surfaceRef === refB);
|
|
540
|
+
if (frames.length === 0) return; // host streams SSE-only or buffered the frame — soft-skip
|
|
541
|
+
const frame = frames[frames.length - 1];
|
|
542
|
+
|
|
543
|
+
// catalogVersion on the delta MUST equal the baseline full surface's.
|
|
544
|
+
expect(
|
|
545
|
+
frame.catalogVersion,
|
|
546
|
+
driver.describe('RFC 0114', 'a delta frame catalogVersion MUST equal the referenced full surface'),
|
|
547
|
+
).toBe(surfaceA.catalogVersion);
|
|
548
|
+
|
|
549
|
+
// Reconstruct: apply the host's real delta to the baseline, re-validate against the closed catalog.
|
|
550
|
+
const reconstructed = applyPatch(surfaceA, frame.patch);
|
|
551
|
+
expect(
|
|
552
|
+
validateRecorded(reconstructed),
|
|
553
|
+
driver.describe('RFC 0114 §"Delta transport"', 'the post-patch surface MUST re-validate against the closed catalog'),
|
|
554
|
+
).toBe(true);
|
|
555
|
+
|
|
556
|
+
// Non-negotiating subscriber: the host materializes the FULL surface for the same update.
|
|
557
|
+
const fullEvents = await getEvents(runId, false);
|
|
558
|
+
if (fullEvents === null) return;
|
|
559
|
+
const fulls = collectMatching(fullEvents, validateRecorded);
|
|
560
|
+
if (fulls.length === 0) return; // soft-skip — no full surface observed on the non-delta stream
|
|
561
|
+
const materializedFull = fulls[fulls.length - 1];
|
|
562
|
+
|
|
563
|
+
// delta and full agree — the core RFC 0114 transport guarantee, witnessed live.
|
|
564
|
+
expect(
|
|
565
|
+
reconstructed,
|
|
566
|
+
driver.describe(
|
|
567
|
+
'RFC 0114 §"Delta transport"',
|
|
568
|
+
'a ?a2uiDelta=1 reconstruction MUST equal the full surface a non-negotiating subscriber materializes',
|
|
569
|
+
),
|
|
570
|
+
).toEqual(materializedFull);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('the emit-surface seam rejects an out-of-catalog surface fail-closed (real catalog gate)', async () => {
|
|
574
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
575
|
+
if (disco.status !== 200) return;
|
|
576
|
+
const cap = capabilityFamily<A2uiSurfaceCap>(disco.json, 'a2uiSurface');
|
|
577
|
+
if (cap?.deltaTransport !== true) return;
|
|
578
|
+
|
|
579
|
+
const runId = `conf-a2ui-delta-oob-${Date.now()}`;
|
|
580
|
+
const baseline = await driver.post('/v1/host/sample/a2ui/emit-surface', { runId, surface: fullSurfaceV0() });
|
|
581
|
+
if (baseline.status === 404 || baseline.status === 405) return; // seam absent — soft-skip
|
|
582
|
+
expect(baseline.status, driver.describe('RFC 0114 §15', 'baseline emit MUST succeed')).toBeLessThan(300);
|
|
583
|
+
|
|
584
|
+
// An out-of-catalog surface MUST be rejected by the host's REAL closed-catalog
|
|
585
|
+
// validator — no delta transported, the a2ui-surface-no-code-exec boundary holds.
|
|
586
|
+
const outOfCatalog = {
|
|
587
|
+
catalogVersion: CATALOG_VERSION,
|
|
588
|
+
surface: { components: [{ component: 'iframe', src: 'https://evil.example/x' }] },
|
|
589
|
+
};
|
|
590
|
+
const rejected = await driver.post('/v1/host/sample/a2ui/emit-surface', { runId, surface: outOfCatalog });
|
|
591
|
+
if (rejected.status === 404 || rejected.status === 405) return;
|
|
592
|
+
expect(
|
|
593
|
+
rejected.status,
|
|
594
|
+
driver.describe(
|
|
595
|
+
'RFC 0114 §15 / a2ui-surface-no-code-exec',
|
|
596
|
+
'an out-of-catalog surface MUST be rejected fail-closed by the host real catalog validator',
|
|
597
|
+
),
|
|
598
|
+
).toBeGreaterThanOrEqual(400);
|
|
599
|
+
});
|
|
600
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel presence — behavioral leg (RFC 0110 §Conformance).
|
|
3
|
+
*
|
|
4
|
+
* Gated on `capabilities.channelPresence.supported` via `behaviorGate`: soft-skips when
|
|
5
|
+
* unadvertised (default) / hard-fails under `OPENWOP_REQUIRE_BEHAVIOR=true`. The companion
|
|
6
|
+
* always-on wire-shape coverage lives in `channel-presence-shape.test.ts`; THIS scenario
|
|
7
|
+
* asserts host BEHAVIOR — the runtime MUSTs JSON Schema cannot express.
|
|
8
|
+
*
|
|
9
|
+
* RFC 0110 carries presence over a host SSE (a held connection a conformance client can't
|
|
10
|
+
* assert against), and mints no normative client route to open presence. The driver
|
|
11
|
+
* therefore reads a live `channel.presence` snapshot via the conformance-only seam
|
|
12
|
+
* `POST /v1/host/sample/channel-presence/snapshot` (`host-sample-test-seams.md`), which
|
|
13
|
+
* routes through the SAME membership gate + the closed payload the host applies in
|
|
14
|
+
* production (a transient join → snapshot, so `present` is non-vacuous). The seam is
|
|
15
|
+
* OPTIONAL — the scenario soft-skips on `404`/`405`; a capability-advertising host whose
|
|
16
|
+
* presence is bound to a product flow (e.g. the openwop-app channels SSE) witnesses
|
|
17
|
+
* instead via its own host-side route test + an `INTEROP-MATRIX.md` row (the RFC 0086
|
|
18
|
+
* dual-staging, as in `multi-party-conversation-behavioral.test.ts`).
|
|
19
|
+
*
|
|
20
|
+
* Behavioral MUSTs asserted (RFC 0110 §Proposal):
|
|
21
|
+
* 1. SHAPE — a snapshot is the CLOSED `{ conversationId, present, typing }` (no field
|
|
22
|
+
* beyond it — the no-PII guard).
|
|
23
|
+
* 2. MEMBERS-ONLY — every ref in `present`/`typing` is an opaque RFC 0041 subject ref
|
|
24
|
+
* (`user:`/`agent:`), never PII, and a subset of the channel's members.
|
|
25
|
+
* 3. NON-VACUOUS — the snapshotting member appears in `present`.
|
|
26
|
+
*
|
|
27
|
+
* Spec references:
|
|
28
|
+
* - https://github.com/openwop/openwop/blob/main/RFCS/0110-channel-presence.md
|
|
29
|
+
* - https://github.com/openwop/openwop/blob/main/spec/v1/host-sample-test-seams.md
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { describe, it, expect } from 'vitest';
|
|
33
|
+
import { driver } from '../lib/driver.js';
|
|
34
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
35
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
36
|
+
|
|
37
|
+
const PROFILE = 'openwop-channel-presence';
|
|
38
|
+
const SUBJECT_REF = /^(user|agent):.+/;
|
|
39
|
+
|
|
40
|
+
interface PresenceSnapshot { conversationId: string; present: string[]; typing: string[] }
|
|
41
|
+
|
|
42
|
+
describe('channel-presence-behavioral (RFC 0110 §Conformance)', () => {
|
|
43
|
+
it('a presence snapshot is the closed, members-only, non-vacuous channel.presence shape', async () => {
|
|
44
|
+
const cap = await readCapabilityFamily<{ supported?: boolean }>('channelPresence');
|
|
45
|
+
const advertised = cap?.supported === true;
|
|
46
|
+
if (!behaviorGate(PROFILE, advertised)) return;
|
|
47
|
+
|
|
48
|
+
const conversationId = 'conf:channel-presence:c1';
|
|
49
|
+
const res = await driver.post('/v1/host/sample/channel-presence/snapshot', { conversationId, member: 'user:conformance-runner' });
|
|
50
|
+
if (res.status === 404 || res.status === 405) return; // seam unwired — host witnesses via its own route test (dual-staging)
|
|
51
|
+
|
|
52
|
+
expect(
|
|
53
|
+
res.status === 200,
|
|
54
|
+
driver.describe('RFC 0110 §Proposal', 'the presence snapshot seam MUST return 200 for a member'),
|
|
55
|
+
).toBe(true);
|
|
56
|
+
const snap = res.json as PresenceSnapshot;
|
|
57
|
+
|
|
58
|
+
// MUST 1 — CLOSED shape: exactly conversationId / present / typing (no PII field rides).
|
|
59
|
+
expect(
|
|
60
|
+
Object.keys(snap).sort().join(','),
|
|
61
|
+
driver.describe('RFC 0110 §Proposal', 'channel.presence carries ONLY conversationId/present/typing (no PII)'),
|
|
62
|
+
).toBe('conversationId,present,typing');
|
|
63
|
+
expect(snap.conversationId, driver.describe('RFC 0110 §Proposal', 'the snapshot echoes the conversationId')).toBe(conversationId);
|
|
64
|
+
|
|
65
|
+
// MUST 2 — every ref is an opaque RFC 0041 subject ref (no PII).
|
|
66
|
+
for (const ref of [...snap.present, ...snap.typing]) {
|
|
67
|
+
expect(
|
|
68
|
+
SUBJECT_REF.test(ref),
|
|
69
|
+
driver.describe('RFC 0110 §Proposal / RFC 0041', `present/typing ref "${ref}" MUST be an opaque user:/agent: subject ref`),
|
|
70
|
+
).toBe(true);
|
|
71
|
+
}
|
|
72
|
+
// typing is a subset of present.
|
|
73
|
+
for (const ref of snap.typing) {
|
|
74
|
+
expect(snap.present.includes(ref), driver.describe('RFC 0110 §Proposal', 'every typing ref MUST also be present')).toBe(true);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// MUST 3 — NON-VACUOUS: the snapshotting member is present.
|
|
78
|
+
expect(
|
|
79
|
+
snap.present.includes('user:conformance-runner'),
|
|
80
|
+
driver.describe('RFC 0110 §Proposal', 'the snapshotting member MUST appear in present (members-only, real)'),
|
|
81
|
+
).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
});
|