@openwop/openwop-conformance 1.2.0 → 1.3.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 +65 -0
- package/README.md +2 -2
- package/api/redocly.yaml +15 -0
- package/coverage.md +2 -1
- package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
- package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
- package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
- package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
- package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
- package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
- package/fixtures.md +6 -0
- package/package.json +1 -1
- package/schemas/capabilities.schema.json +16 -0
- package/schemas/core-conformance-mock-agent-config.schema.json +5 -0
- package/schemas/run-event-payloads.schema.json +35 -1
- package/schemas/run-event.schema.json +2 -0
- package/src/lib/driver.ts +15 -0
- package/src/lib/env.ts +51 -0
- package/src/lib/event-log-query.ts +62 -0
- package/src/lib/fixtures.ts +38 -1
- package/src/lib/host-toggle.ts +54 -0
- package/src/lib/multi-agent-capabilities.ts +10 -0
- package/src/lib/otel-scrape.ts +59 -0
- package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +97 -9
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +128 -10
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +236 -21
- package/src/scenarios/aiEnvelope.redaction.test.ts +204 -24
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +158 -19
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +59 -8
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +100 -9
- package/src/scenarios/blob-presign-expiry.test.ts +35 -2
- package/src/scenarios/blob-roundtrip.test.ts +0 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +28 -2
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +34 -3
- package/src/scenarios/dispatch-input-mapping.test.ts +75 -6
- package/src/scenarios/dispatch-output-mapping.test.ts +96 -6
- package/src/scenarios/fixtures-gating.test.ts +139 -1
- package/src/scenarios/kv-ttl-expiry.test.ts +33 -2
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
- package/src/scenarios/pack-registry-publish.test.ts +231 -51
- package/src/scenarios/provider-usage.test.ts +185 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +57 -3
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +43 -3
- package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
- package/src/scenarios/search-bm25-roundtrip.test.ts +47 -2
- package/src/scenarios/sql-transaction-atomicity.test.ts +31 -2
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +39 -2
- package/src/scenarios/subworkflow-input-mapping.test.ts +77 -7
- package/src/scenarios/table-cursor-pagination.test.ts +40 -2
- package/src/scenarios/table-schema-enforcement.test.ts +39 -2
- package/src/scenarios/vector-knn-roundtrip.test.ts +43 -3
- package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
|
@@ -65,23 +65,162 @@ describe('aiEnvelope.schemaDrift: advertisement shape (FINAL v1.1)', () => {
|
|
|
65
65
|
});
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
68
|
+
// Behavioral assertions through the workflow-engine sample's env-gated
|
|
69
|
+
// `POST /v1/host/sample/envelope/accept` seam. The seam threads
|
|
70
|
+
// `schemaVersionFloor` + `envelopeStrictness` into AcceptOptions so the
|
|
71
|
+
// pure-function acceptor can apply the §"Schema discipline" gate.
|
|
72
|
+
// Each test soft-skips on HTTP 404 (host doesn't expose the seam).
|
|
73
|
+
async function accept(envelope: unknown, opts: Record<string, unknown> = {}): Promise<{ status: number; body: { status?: string; reason?: string; details?: unknown[] } }> {
|
|
74
|
+
const res = await driver.post('/v1/host/sample/envelope/accept', { envelope, ...opts });
|
|
75
|
+
return { status: res.status, body: res.json as { status?: string; reason?: string; details?: unknown[] } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const baseMeta = { source: 'ai-generation' as const, ts: '2026-05-18T10:00:00Z' };
|
|
79
|
+
|
|
80
|
+
describe('aiEnvelope.schemaDrift: behavioral strictness gate (FINAL v1.1)', () => {
|
|
81
|
+
it('schemaVersion below advertised floor under strictness:"warn" → accepted (warn-and-continue)', async () => {
|
|
82
|
+
const r = await accept(
|
|
83
|
+
{
|
|
84
|
+
type: 'clarification.request',
|
|
85
|
+
schemaVersion: 0, // below the v1 floor
|
|
86
|
+
envelopeId: 'env-drift-warn',
|
|
87
|
+
correlationId: 'r:n:0:driftwarn',
|
|
88
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
89
|
+
meta: baseMeta,
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
schemaVersionFloor: { 'clarification.request': 1 },
|
|
93
|
+
envelopeStrictness: 'warn',
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
if (r.status === 404) return;
|
|
97
|
+
expect(
|
|
98
|
+
r.body.status,
|
|
99
|
+
driver.describe(
|
|
100
|
+
'ai-envelope.md §"Schema discipline"',
|
|
101
|
+
'below-floor schemaVersion under strictness:warn MUST be accepted (drift projected at engine level)',
|
|
102
|
+
),
|
|
103
|
+
).toBe('accepted');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('schemaVersion below advertised floor under strictness:"strict" → invalid unknown_schema_version', async () => {
|
|
107
|
+
const r = await accept(
|
|
108
|
+
{
|
|
109
|
+
type: 'clarification.request',
|
|
110
|
+
schemaVersion: 0,
|
|
111
|
+
envelopeId: 'env-drift-strict',
|
|
112
|
+
correlationId: 'r:n:0:driftstrict',
|
|
113
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
114
|
+
meta: baseMeta,
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
schemaVersionFloor: { 'clarification.request': 1 },
|
|
118
|
+
envelopeStrictness: 'strict',
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
if (r.status === 404) return;
|
|
122
|
+
expect(
|
|
123
|
+
r.body.status,
|
|
124
|
+
driver.describe(
|
|
125
|
+
'ai-envelope.md §"Schema discipline"',
|
|
126
|
+
'below-floor schemaVersion under strictness:strict MUST refuse with unknown_schema_version',
|
|
127
|
+
),
|
|
128
|
+
).toBe('invalid');
|
|
129
|
+
expect(r.body.reason).toContain('unknown_schema_version');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('schemaVersion ABOVE advertised floor → invalid regardless of strictness (host doesn\'t know future version)', async () => {
|
|
133
|
+
for (const strictness of ['warn', 'strict'] as const) {
|
|
134
|
+
const r = await accept(
|
|
135
|
+
{
|
|
136
|
+
type: 'clarification.request',
|
|
137
|
+
schemaVersion: 99,
|
|
138
|
+
envelopeId: `env-drift-above-${strictness}`,
|
|
139
|
+
correlationId: `r:n:0:driftabove-${strictness}`,
|
|
140
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
141
|
+
meta: baseMeta,
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
schemaVersionFloor: { 'clarification.request': 1 },
|
|
145
|
+
envelopeStrictness: strictness,
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
if (r.status === 404) return;
|
|
149
|
+
expect(
|
|
150
|
+
r.body.status,
|
|
151
|
+
driver.describe(
|
|
152
|
+
'ai-envelope.md §"Schema discipline"',
|
|
153
|
+
`above-floor schemaVersion MUST refuse regardless of strictness (got ${strictness})`,
|
|
154
|
+
),
|
|
155
|
+
).toBe('invalid');
|
|
156
|
+
expect(r.body.reason).toContain('unknown_schema_version');
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('refused above-floor envelope carries instancePath /schemaVersion in details', async () => {
|
|
161
|
+
const r = await accept(
|
|
162
|
+
{
|
|
163
|
+
type: 'error',
|
|
164
|
+
schemaVersion: 5,
|
|
165
|
+
envelopeId: 'env-drift-details',
|
|
166
|
+
correlationId: 'r:n:0:driftdetails',
|
|
167
|
+
payload: { code: 'x', message: 'y' },
|
|
168
|
+
meta: baseMeta,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
schemaVersionFloor: { error: 1 },
|
|
172
|
+
envelopeStrictness: 'warn', // above-floor → invalid regardless
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
if (r.status === 404) return;
|
|
176
|
+
expect(r.body.status).toBe('invalid');
|
|
177
|
+
expect(Array.isArray(r.body.details)).toBe(true);
|
|
178
|
+
const paths = (r.body.details ?? []).map((d: unknown) => (d as { instancePath?: string }).instancePath);
|
|
179
|
+
expect(
|
|
180
|
+
paths.includes('/schemaVersion'),
|
|
181
|
+
driver.describe(
|
|
182
|
+
'ai-envelope.md §"Schema discipline"',
|
|
183
|
+
'schema-drift refusal MUST cite /schemaVersion as the violating field',
|
|
184
|
+
),
|
|
185
|
+
).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// E.2 OTel scrape seam.
|
|
190
|
+
import { queryTestSpans, isOtelSeamAvailable } from '../lib/otel-scrape.js';
|
|
191
|
+
import { resetTestSeam } from '../lib/event-log-query.js';
|
|
192
|
+
|
|
193
|
+
describe('aiEnvelope.schemaDrift: OTel drift attribute projection (E.2)', () => {
|
|
194
|
+
it('below-floor + strictness:warn → OTel span MUST carry envelope_schema_version_drift attribute', async () => {
|
|
195
|
+
if (!(await isOtelSeamAvailable())) return;
|
|
196
|
+
const runId = `r-drift-otel-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
197
|
+
const r = await accept(
|
|
198
|
+
{
|
|
199
|
+
type: 'clarification.request',
|
|
200
|
+
schemaVersion: 0, // below the v1 floor
|
|
201
|
+
envelopeId: 'env-drift-otel-1',
|
|
202
|
+
correlationId: `${runId}:n:0:drift-otel`,
|
|
203
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
204
|
+
meta: baseMeta,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
schemaVersionFloor: { 'clarification.request': 1 },
|
|
208
|
+
envelopeStrictness: 'warn',
|
|
209
|
+
projectTo: { runId, nodeId: 'n' },
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
if (r.status === 404) return;
|
|
213
|
+
expect(r.body.status).toBe('accepted');
|
|
214
|
+
|
|
215
|
+
const spans = await queryTestSpans({ runId });
|
|
216
|
+
if (!spans.ok) return;
|
|
217
|
+
expect(
|
|
218
|
+
spans.data.some((s) => s.attributes.envelope_schema_version_drift === true),
|
|
219
|
+
driver.describe(
|
|
220
|
+
'ai-envelope.md §"Schema discipline"',
|
|
221
|
+
'below-floor accept under strictness:warn MUST project envelope_schema_version_drift attribute on the OTel span',
|
|
222
|
+
),
|
|
223
|
+
).toBe(true);
|
|
224
|
+
await resetTestSeam();
|
|
225
|
+
});
|
|
87
226
|
});
|
|
@@ -132,12 +132,63 @@ describe('aiEnvelope.trustBoundaryPropagation: behavioral normalization (FINAL v
|
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
135
|
+
// E.1 engine-projection via the test-only event-log seam.
|
|
136
|
+
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
137
|
+
|
|
138
|
+
describe('aiEnvelope.trustBoundaryPropagation: engine projection via event-log seam', () => {
|
|
139
|
+
it('normalizedMeta.contentTrust:"untrusted" MUST project onto RunEventDoc.contentTrust', async () => {
|
|
140
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
141
|
+
const runId = `r-tb-proj-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
142
|
+
await accept(
|
|
143
|
+
{
|
|
144
|
+
type: 'clarification.request',
|
|
145
|
+
schemaVersion: 1,
|
|
146
|
+
envelopeId: 'env-tb-proj-1',
|
|
147
|
+
correlationId: `${runId}:n:0:tb-proj`,
|
|
148
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
149
|
+
meta: { ...baseMeta, contentTrust: 'untrusted' },
|
|
150
|
+
},
|
|
151
|
+
{ projectTo: { runId, nodeId: 'n' } },
|
|
152
|
+
);
|
|
153
|
+
const events = await queryTestEvents(runId, { type: 'interrupt.requested' });
|
|
154
|
+
if (!events.ok || events.events.length === 0) return;
|
|
155
|
+
expect(
|
|
156
|
+
events.events[0]!.contentTrust,
|
|
157
|
+
driver.describe(
|
|
158
|
+
'ai-envelope.md §"Trust boundary"',
|
|
159
|
+
'engine MUST project normalizedMeta.contentTrust:"untrusted" onto every consequent RunEventDoc.contentTrust',
|
|
160
|
+
),
|
|
161
|
+
).toBe('untrusted');
|
|
162
|
+
await resetTestSeam();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('trusted envelope projects RunEventDoc.contentTrust:"trusted" (default + explicit both verified)', async () => {
|
|
166
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
167
|
+
const runId = `r-tb-trusted-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
168
|
+
await accept(
|
|
169
|
+
{
|
|
170
|
+
type: 'clarification.request',
|
|
171
|
+
schemaVersion: 1,
|
|
172
|
+
envelopeId: 'env-tb-proj-trusted',
|
|
173
|
+
correlationId: `${runId}:n:0:tb-trusted`,
|
|
174
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
175
|
+
meta: baseMeta, // no contentTrust → default 'trusted'
|
|
176
|
+
},
|
|
177
|
+
{ projectTo: { runId, nodeId: 'n' } },
|
|
178
|
+
);
|
|
179
|
+
const events = await queryTestEvents(runId, { type: 'interrupt.requested' });
|
|
180
|
+
if (!events.ok || events.events.length === 0) return;
|
|
181
|
+
expect(events.events[0]!.contentTrust).toBe('trusted');
|
|
182
|
+
await resetTestSeam();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('aiEnvelope.trustBoundaryPropagation: approval-gate refusal placeholder', () => {
|
|
187
|
+
// Approval-gate refusal (`untrusted_content_blocks_approval`) requires
|
|
188
|
+
// wiring the acceptor's normalizedMeta onto the engine's approval-gate
|
|
189
|
+
// resume handler. Tracked under Thread E.4 of the test-coverage plan
|
|
190
|
+
// (approval-gate refusal seam); the projection seam alone can't drive
|
|
191
|
+
// a resume-with-untrusted assertion.
|
|
192
|
+
it.todo('approval gate refuses to advance on untrusted envelope with untrusted_content_blocks_approval (needs approval-gate resume seam)');
|
|
193
|
+
it.todo('downstream LLM node re-consuming untrusted RunEventDoc applies <UNTRUSTED> wrap per prompt-injection invariant (needs node-execution seam)');
|
|
143
194
|
});
|
|
@@ -164,13 +164,104 @@ describe('aiEnvelope.universalKinds: behavioral accept via /v1/host/sample/envel
|
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
167
|
+
// E.1 engine-projection via the test-only event-log seam.
|
|
168
|
+
import { queryTestEvents, isEventLogSeamAvailable, resetTestSeam } from '../lib/event-log-query.js';
|
|
169
|
+
|
|
170
|
+
describe('aiEnvelope.universalKinds: engine projection via event-log seam', () => {
|
|
171
|
+
it('clarification.request MUST be lifted to interrupt.requested { kind: "clarification" } per interrupt.md', async () => {
|
|
172
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
173
|
+
const runId = `r-uk-clar-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
174
|
+
const r = await accept(
|
|
175
|
+
{
|
|
176
|
+
type: 'clarification.request',
|
|
177
|
+
schemaVersion: 1,
|
|
178
|
+
envelopeId: 'env-uk-proj-clar',
|
|
179
|
+
correlationId: `${runId}:n:0:uk-clar`,
|
|
180
|
+
payload: { questions: [{ id: 'q1', question: 'why?' }] },
|
|
181
|
+
meta: baseMeta,
|
|
182
|
+
},
|
|
183
|
+
{ projectTo: { runId, nodeId: 'n' } },
|
|
184
|
+
);
|
|
185
|
+
if (r.status === 404) return;
|
|
186
|
+
expect(r.body.status).toBe('accepted');
|
|
187
|
+
const events = await queryTestEvents(runId, { type: 'interrupt.requested' });
|
|
188
|
+
if (!events.ok) return;
|
|
189
|
+
expect(
|
|
190
|
+
events.events.length,
|
|
191
|
+
driver.describe('ai-envelope.md §"Universal kinds"', 'accepted clarification.request MUST project to interrupt.requested per interrupt.md'),
|
|
192
|
+
).toBe(1);
|
|
193
|
+
expect((events.events[0]!.payload as { kind?: string }).kind).toBe('clarification');
|
|
194
|
+
await resetTestSeam();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('error envelope MUST project to log.appended { level: "error" } — NOT node.failed', async () => {
|
|
198
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
199
|
+
const runId = `r-uk-err-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
200
|
+
await accept(
|
|
201
|
+
{
|
|
202
|
+
type: 'error',
|
|
203
|
+
schemaVersion: 1,
|
|
204
|
+
envelopeId: 'env-uk-proj-err',
|
|
205
|
+
correlationId: `${runId}:n:0:uk-err`,
|
|
206
|
+
payload: { code: 'validation_failed', message: 'cannot produce JSON' },
|
|
207
|
+
meta: baseMeta,
|
|
208
|
+
},
|
|
209
|
+
{ projectTo: { runId, nodeId: 'n' } },
|
|
210
|
+
);
|
|
211
|
+
const logs = await queryTestEvents(runId, { type: 'log.appended' });
|
|
212
|
+
const fails = await queryTestEvents(runId, { type: 'node.failed' });
|
|
213
|
+
if (!logs.ok || !fails.ok) return;
|
|
214
|
+
expect(
|
|
215
|
+
logs.events.some((e) => (e.payload as { level?: string }).level === 'error'),
|
|
216
|
+
driver.describe('ai-envelope.md §"Universal kinds"', 'LLM-emitted error envelope MUST project to log.appended at error level'),
|
|
217
|
+
).toBe(true);
|
|
218
|
+
expect(
|
|
219
|
+
fails.events.length,
|
|
220
|
+
driver.describe('ai-envelope.md §"Universal kinds"', 'LLM-emitted error envelope MUST NOT project to node.failed (distinct from terminal node failure)'),
|
|
221
|
+
).toBe(0);
|
|
222
|
+
await resetTestSeam();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('schema.request projects to log.appended (host implements next-turn injection out-of-band)', async () => {
|
|
226
|
+
if (!(await isEventLogSeamAvailable())) return;
|
|
227
|
+
const runId = `r-uk-sr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
228
|
+
await accept(
|
|
229
|
+
{
|
|
230
|
+
type: 'schema.request',
|
|
231
|
+
schemaVersion: 1,
|
|
232
|
+
envelopeId: 'env-uk-proj-sr',
|
|
233
|
+
correlationId: `${runId}:n:0:uk-sr`,
|
|
234
|
+
payload: { envelopeType: 'vendor.acme.foo' },
|
|
235
|
+
meta: baseMeta,
|
|
236
|
+
},
|
|
237
|
+
{ projectTo: { runId, nodeId: 'n' } },
|
|
238
|
+
);
|
|
239
|
+
const events = await queryTestEvents(runId, { type: 'log.appended' });
|
|
240
|
+
if (!events.ok) return;
|
|
241
|
+
expect(
|
|
242
|
+
events.events.length,
|
|
243
|
+
driver.describe('ai-envelope.md §"Universal kinds"', 'schema.request MUST project to log.appended (the schema delivery itself happens out-of-band via the host\'s next-turn system prompt)'),
|
|
244
|
+
).toBeGreaterThan(0);
|
|
245
|
+
await resetTestSeam();
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('aiEnvelope.universalKinds: schema.response counter-policy advertisement (ai-envelope.md §"Universal kinds")', () => {
|
|
250
|
+
it('host MAY count or exempt schema.response against envelopesPerTurn; when advertised, the policy field MUST be a documented enum value', async () => {
|
|
251
|
+
// Per ai-envelope.md §"Universal kinds": "Engines MAY count this against
|
|
252
|
+
// Capabilities.limits.envelopesPerTurn or exempt it; conformance does
|
|
253
|
+
// not lock this choice." The conformance test only verifies that hosts
|
|
254
|
+
// advertising a policy field use a documented value.
|
|
255
|
+
const res = await driver.get('/.well-known/openwop');
|
|
256
|
+
const body = res.json as { capabilities?: { aiEnvelope?: { schemaResponseCounterPolicy?: string } } } | undefined;
|
|
257
|
+
const policy = body?.capabilities?.aiEnvelope?.schemaResponseCounterPolicy;
|
|
258
|
+
if (policy === undefined) return; // no policy advertised — host MAY omit
|
|
259
|
+
expect(
|
|
260
|
+
['counted', 'exempt'].includes(policy),
|
|
261
|
+
driver.describe(
|
|
262
|
+
'ai-envelope.md §"Universal kinds"',
|
|
263
|
+
'when advertised, schemaResponseCounterPolicy MUST be either "counted" or "exempt"',
|
|
264
|
+
),
|
|
265
|
+
).toBe(true);
|
|
266
|
+
});
|
|
176
267
|
});
|
|
@@ -61,6 +61,39 @@ describe('blob-presign-expiry: advertisement shape (RFC 0019)', () => {
|
|
|
61
61
|
});
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
64
|
+
async function call(op: string, args: Record<string, unknown>) {
|
|
65
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'blob', op, args });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('blob-presign-expiry: behavioral (RFC 0019 §B point 1)', () => {
|
|
69
|
+
it('presigned URL MUST resolve to the blob inside its TTL window and return 403 after expiry', async () => {
|
|
70
|
+
const probe = await call('get', { key: '__probe__' });
|
|
71
|
+
if (probe.status === 404) return; // seam not exposed
|
|
72
|
+
const key = `pre-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
73
|
+
const contentBase64 = Buffer.from('presigned-payload').toString('base64');
|
|
74
|
+
await call('put', { key, contentBase64, contentType: 'text/plain' });
|
|
75
|
+
|
|
76
|
+
// presign with TTL=2s
|
|
77
|
+
const presign = await call('presign', { key, expiresInSeconds: 2 });
|
|
78
|
+
expect(presign.status).toBe(200);
|
|
79
|
+
const body = presign.json as { url?: string; expiresAtMs?: number };
|
|
80
|
+
expect(typeof body.url, 'presign MUST return a URL').toBe('string');
|
|
81
|
+
|
|
82
|
+
// Fetch within the window — MUST return 200 + the bytes
|
|
83
|
+
const within = await driver.get(body.url!);
|
|
84
|
+
if (within.status === 404) return; // host doesn't expose the resolver route — soft-skip the expiry side too
|
|
85
|
+
expect(
|
|
86
|
+
within.status,
|
|
87
|
+
driver.describe('RFC 0019 §B point 1', 'presigned URL MUST resolve to 200 within its TTL window'),
|
|
88
|
+
).toBe(200);
|
|
89
|
+
|
|
90
|
+
// Wait past expiry (TTL=2s + 1s buffer)
|
|
91
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
92
|
+
|
|
93
|
+
const after = await driver.get(body.url!);
|
|
94
|
+
expect(
|
|
95
|
+
after.status,
|
|
96
|
+
driver.describe('RFC 0019 §B point 1', 'presigned URL MUST return 403 after TTL expiry'),
|
|
97
|
+
).toBe(403);
|
|
98
|
+
});
|
|
66
99
|
});
|
|
Binary file
|
|
@@ -42,6 +42,32 @@ describe('cache-ttl-expiry: advertisement shape (RFC 0019)', () => {
|
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
async function call(op: string, args: Record<string, unknown>) {
|
|
46
|
+
return driver.post('/v1/host/sample/test/surface', { tenantId: 'tenant-a', surface: 'cache', op, args });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe('cache-ttl-expiry: behavioral (RFC 0019 §B point 2 — 1s TTL drift)', () => {
|
|
50
|
+
it('put with ttlSeconds=2 → hit within window; miss after expiry', async () => {
|
|
51
|
+
const probe = await call('get', { key: '__cache-probe__' });
|
|
52
|
+
if (probe.status === 404) return;
|
|
53
|
+
const key = `c-ttl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
54
|
+
const putRes = await call('put', { key, value: 'evicts-soon', ttlSeconds: 2 });
|
|
55
|
+
expect(putRes.status).toBe(200);
|
|
56
|
+
|
|
57
|
+
const within = await call('get', { key });
|
|
58
|
+
const withinBody = within.json as { value?: unknown; found?: boolean };
|
|
59
|
+
expect(
|
|
60
|
+
withinBody.value,
|
|
61
|
+
driver.describe('RFC 0019 §B point 2', 'cache get within TTL MUST return the stored value'),
|
|
62
|
+
).toBe('evicts-soon');
|
|
63
|
+
|
|
64
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
65
|
+
|
|
66
|
+
const after = await call('get', { key });
|
|
67
|
+
const afterBody = after.json as { value?: unknown; found?: boolean };
|
|
68
|
+
expect(
|
|
69
|
+
afterBody.found,
|
|
70
|
+
driver.describe('RFC 0019 §B point 2', 'cache get after TTL expiry MUST surface as found:false (≤1s drift)'),
|
|
71
|
+
).toBe(false);
|
|
72
|
+
});
|
|
47
73
|
});
|
|
@@ -92,7 +92,38 @@ describe.skipIf(SKIP)('dispatch-cross-worker-handoff: sequential child→parent
|
|
|
92
92
|
)).toBe('hello');
|
|
93
93
|
});
|
|
94
94
|
|
|
95
|
-
it
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
it('HVMAP-1c-override: per-worker mapping overrides default mapping per §A effectiveInputMapping precedence', async () => {
|
|
96
|
+
const PARENT_OVERRIDE = 'conformance-dispatch-per-worker-override';
|
|
97
|
+
if (!isFixtureAdvertised(PARENT_OVERRIDE)) return; // fixture not seeded — soft-skip
|
|
98
|
+
const create = await driver.post('/v1/runs', { workflowId: PARENT_OVERRIDE });
|
|
99
|
+
expect(create.status).toBe(201);
|
|
100
|
+
const parentRunId = (create.json as { runId: string }).runId;
|
|
101
|
+
await pollUntilTerminal(parentRunId);
|
|
102
|
+
|
|
103
|
+
const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
|
|
104
|
+
const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
|
|
105
|
+
const dispatchedA = events.find((e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD_A);
|
|
106
|
+
const dispatchedB = events.find((e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD_B);
|
|
107
|
+
if (!dispatchedA || !dispatchedB) return;
|
|
108
|
+
|
|
109
|
+
const childARes = await driver.get(`/v1/runs/${encodeURIComponent(dispatchedA.payload!.childRunId!)}`);
|
|
110
|
+
const childBRes = await driver.get(`/v1/runs/${encodeURIComponent(dispatchedB.payload!.childRunId!)}`);
|
|
111
|
+
const childAInputs = (childARes.json as { inputs?: Record<string, unknown> }).inputs ?? {};
|
|
112
|
+
const childBInputs = (childBRes.json as { inputs?: Record<string, unknown> }).inputs ?? {};
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
childAInputs.input,
|
|
116
|
+
driver.describe(
|
|
117
|
+
'RFCS/0022-dispatch-input-output-mapping.md §A',
|
|
118
|
+
'child-a uses the DEFAULT inputMapping; input MUST come from parent.defaultX',
|
|
119
|
+
),
|
|
120
|
+
).toBe('default-x-value');
|
|
121
|
+
expect(
|
|
122
|
+
childBInputs.input,
|
|
123
|
+
driver.describe(
|
|
124
|
+
'RFCS/0022-dispatch-input-output-mapping.md §A',
|
|
125
|
+
'child-b uses the per-worker OVERRIDE; input MUST come from parent.sharedVar (NOT defaultX)',
|
|
126
|
+
),
|
|
127
|
+
).toBe('shared-value');
|
|
128
|
+
});
|
|
98
129
|
});
|
|
@@ -24,6 +24,7 @@ import { describe, it, expect } from 'vitest';
|
|
|
24
24
|
import { driver } from '../lib/driver.js';
|
|
25
25
|
import { pollUntilTerminal } from '../lib/polling.js';
|
|
26
26
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
27
|
+
import { setHostCapability, resetHostCapabilities, isToggleAvailable } from '../lib/host-toggle.js';
|
|
27
28
|
|
|
28
29
|
const PARENT = 'conformance-dispatch-input-mapping';
|
|
29
30
|
const CHILD = 'conformance-dispatch-input-mapping-child';
|
|
@@ -84,11 +85,79 @@ describe.skipIf(SKIP)('dispatch-input-mapping: parent → child variable project
|
|
|
84
85
|
)).toBe('Alice');
|
|
85
86
|
});
|
|
86
87
|
|
|
87
|
-
it
|
|
88
|
-
'
|
|
89
|
-
|
|
88
|
+
it('HVMAP-1a-null: parent variable unset → child input surfaces as `undefined` per §A', async () => {
|
|
89
|
+
const PARENT_NO_DEFAULT = 'conformance-dispatch-input-mapping-no-default';
|
|
90
|
+
if (!isFixtureAdvertised(PARENT_NO_DEFAULT) || !isFixtureAdvertised(CHILD)) return; // fixture not seeded — soft-skip
|
|
91
|
+
const create = await driver.post('/v1/runs', { workflowId: PARENT_NO_DEFAULT });
|
|
92
|
+
expect(create.status).toBe(201);
|
|
93
|
+
const parentRunId = (create.json as { runId: string }).runId;
|
|
94
|
+
await pollUntilTerminal(parentRunId);
|
|
90
95
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
96
|
+
const eventsRes = await driver.get(`/v1/runs/${encodeURIComponent(parentRunId)}/events`);
|
|
97
|
+
const events = ((eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? []);
|
|
98
|
+
const dispatched = events.find(
|
|
99
|
+
(e) => e.type === 'node.dispatched' && e.payload?.childWorkflowId === CHILD,
|
|
100
|
+
);
|
|
101
|
+
if (!dispatched) return; // host doesn't emit node.dispatched — soft-skip
|
|
102
|
+
const childRunId = dispatched.payload?.childRunId;
|
|
103
|
+
|
|
104
|
+
const childRes = await driver.get(`/v1/runs/${encodeURIComponent(childRunId!)}`);
|
|
105
|
+
const child = childRes.json as RunSnapshot;
|
|
106
|
+
// Per RFC 0022 §A: an unset parent variable MUST surface as `undefined`.
|
|
107
|
+
// On the wire, `undefined` becomes either omitted from the JSON object
|
|
108
|
+
// OR explicit `null`; the spec REJECTS the latter. We accept either
|
|
109
|
+
// "key absent" or "key === undefined" but FAIL on `null`.
|
|
110
|
+
const inputs = child.inputs ?? {};
|
|
111
|
+
const v = inputs.childGreeting;
|
|
112
|
+
expect(
|
|
113
|
+
v === undefined || !('childGreeting' in inputs),
|
|
114
|
+
driver.describe(
|
|
115
|
+
'RFCS/0022-dispatch-input-output-mapping.md §A',
|
|
116
|
+
'unset parent variable projection MUST surface as undefined (NOT null, NOT a default placeholder)',
|
|
117
|
+
),
|
|
118
|
+
).toBe(true);
|
|
119
|
+
expect(v).not.toBe(null);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('dispatch-input-mapping: registration refusal (RFC 0022 §C HVMAP-1a-refusal)', () => {
|
|
125
|
+
it('host with agents.dispatchMapping toggled OFF MUST refuse non-empty inputMapping at registration', async () => {
|
|
126
|
+
if (!(await isToggleAvailable())) return; // seam not exposed — soft-skip
|
|
127
|
+
await setHostCapability('agents.dispatchMapping', false);
|
|
128
|
+
try {
|
|
129
|
+
const workflow = {
|
|
130
|
+
workflowId: `hvmap-1a-refusal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
131
|
+
nodes: [
|
|
132
|
+
{
|
|
133
|
+
nodeId: 'dispatch-1',
|
|
134
|
+
typeId: 'core.dispatch',
|
|
135
|
+
config: {
|
|
136
|
+
nextWorkerIds: ['child-a'],
|
|
137
|
+
inputMapping: { childInput: 'parentVar' }, // non-empty — refusal trigger
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
const res = await driver.post('/v1/host/sample/workflows', workflow);
|
|
143
|
+
expect(
|
|
144
|
+
res.status,
|
|
145
|
+
driver.describe(
|
|
146
|
+
'RFCS/0022-dispatch-input-output-mapping.md §C',
|
|
147
|
+
'workflow with non-empty inputMapping MUST be refused when capabilities.agents.dispatchMapping is not advertised',
|
|
148
|
+
),
|
|
149
|
+
).toBe(400);
|
|
150
|
+
const body = res.json as { error?: string; details?: { requiredCapability?: string } };
|
|
151
|
+
expect(body.error).toBe('validation_error');
|
|
152
|
+
expect(
|
|
153
|
+
body.details?.requiredCapability,
|
|
154
|
+
driver.describe(
|
|
155
|
+
'RFCS/0022-dispatch-input-output-mapping.md §C',
|
|
156
|
+
'refusal MUST surface requiredCapability: "agents.dispatchMapping"',
|
|
157
|
+
),
|
|
158
|
+
).toBe('agents.dispatchMapping');
|
|
159
|
+
} finally {
|
|
160
|
+
await resetHostCapabilities();
|
|
161
|
+
}
|
|
162
|
+
});
|
|
94
163
|
});
|