@openwop/openwop-conformance 1.21.0 → 1.24.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 +43 -2
- package/README.md +61 -63
- package/api/asyncapi.yaml +108 -38
- package/api/openapi.yaml +34 -6
- package/coverage.md +389 -202
- package/fixtures/connection-packs/connection-pack-github.json +31 -0
- package/fixtures.md +120 -101
- package/package.json +1 -1
- package/schemas/README.md +4 -0
- package/schemas/capabilities.schema.json +127 -0
- package/schemas/connection-pack-manifest.schema.json +161 -0
- package/schemas/export-bundle.schema.json +66 -0
- package/schemas/goal.schema.json +104 -0
- package/schemas/proposal.schema.json +84 -0
- package/schemas/run-event-payloads.schema.json +86 -7
- package/schemas/run-event.schema.json +17 -3
- package/schemas/run-options.schema.json +1 -2
- package/schemas/run-snapshot.schema.json +2 -1
- package/schemas/suspend-request.schema.json +5 -0
- package/src/scenarios/connection-pack-manifest-valid.test.ts +122 -0
- package/src/scenarios/connection-pack-no-credential-material.test.ts +125 -0
- package/src/scenarios/connection-pack-reach-exclusive.test.ts +85 -0
- package/src/scenarios/connection-pack-write-reconsent.test.ts +91 -0
- package/src/scenarios/connection-provider-resolution.test.ts +153 -0
- package/src/scenarios/cross-host-traceparent-propagation.test.ts +3 -3
- package/src/scenarios/export-bundle-portability.test.ts +120 -0
- package/src/scenarios/fixtures-valid.test.ts +34 -0
- package/src/scenarios/goal-standing-continuation.test.ts +139 -0
- package/src/scenarios/grpc-transport.test.ts +108 -0
- package/src/scenarios/i18n-negotiation.test.ts +181 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +2 -2
- package/src/scenarios/media-url-inline-cap.test.ts +5 -3
- package/src/scenarios/proposal-reviewable-learning.test.ts +129 -0
- package/src/scenarios/spec-corpus-validity.test.ts +107 -0
- package/src/scenarios/stream-text-fixture.test.ts +212 -0
- package/src/scenarios/version-fold.test.ts +193 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +4 -2
- package/src/scenarios/webhook-tenant-isolation.test.ts +184 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* i18n-negotiation — `spec/v1/i18n.md` locale negotiation (capability-gated).
|
|
3
|
+
*
|
|
4
|
+
* Always-on (when a host is reachable): the `i18n` advertisement-shape
|
|
5
|
+
* probe — absent is conformant ("host serves a single locale always");
|
|
6
|
+
* when present, `supported` is a boolean, `defaultLocale` /
|
|
7
|
+
* `supportedLocales` are BCP 47 tags, and `supportedLocales` contains
|
|
8
|
+
* `defaultLocale` (i18n.md §"Capability advertisement").
|
|
9
|
+
*
|
|
10
|
+
* Behavioral legs are gated via `behaviorGate('openwop-i18n',
|
|
11
|
+
* i18n.supported === true)` — soft-skip by default, hard-fail under
|
|
12
|
+
* `OPENWOP_REQUIRE_BEHAVIOR=true` (root-first family read per RFC 0073):
|
|
13
|
+
*
|
|
14
|
+
* 1. An UNSUPPORTED `Accept-Language` MUST NOT fail the request — the
|
|
15
|
+
* host falls back to its default locale (i18n.md §Accept-Language
|
|
16
|
+
* "Host MUST NOT" + §Fallback rules).
|
|
17
|
+
* 2. A MALFORMED `Accept-Language` MUST NOT cause `400` (i18n.md
|
|
18
|
+
* §Accept-Language Host-MUST rule 1).
|
|
19
|
+
* 3. `Content-Language`, when emitted, is a BCP 47 tag and never lies:
|
|
20
|
+
* for the unsupported-tag request it reflects the host default
|
|
21
|
+
* (i18n.md §Fallback rules 3–4 + §Conformance).
|
|
22
|
+
* 4. The machine-readable error `code` stays the canonical English /
|
|
23
|
+
* lowercase / underscore token regardless of the negotiated
|
|
24
|
+
* language (i18n.md §"`locale` field on `ErrorEnvelope.details`").
|
|
25
|
+
*
|
|
26
|
+
* The probes drive a protected route that produces a human-facing error
|
|
27
|
+
* envelope (`GET /v1/runs/{unknown-id}` → `404 not_found`) so both the
|
|
28
|
+
* negotiation tolerance AND the error-code stability are observable
|
|
29
|
+
* black-box without any fixture.
|
|
30
|
+
*
|
|
31
|
+
* @see spec/v1/i18n.md
|
|
32
|
+
* @see schemas/capabilities.schema.json §i18n
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect } from 'vitest';
|
|
36
|
+
import { driver } from '../lib/driver.js';
|
|
37
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
38
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
39
|
+
|
|
40
|
+
const HTTP_SKIP = !process.env.OPENWOP_BASE_URL;
|
|
41
|
+
|
|
42
|
+
/** BCP 47 well-formedness as pinned by capabilities.schema.json §i18n. */
|
|
43
|
+
const BCP47 = /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8}){0,3}$/;
|
|
44
|
+
|
|
45
|
+
interface I18nCap {
|
|
46
|
+
readonly supported?: unknown;
|
|
47
|
+
readonly defaultLocale?: unknown;
|
|
48
|
+
readonly supportedLocales?: unknown;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A protected route that yields a canonical error envelope. */
|
|
52
|
+
const PROBE_PATH = '/v1/runs/openwop-conformance-i18n-no-such-run';
|
|
53
|
+
|
|
54
|
+
/** Pick a syntactically valid tag the host does NOT list as supported. */
|
|
55
|
+
function pickUnsupportedTag(supportedLocales: readonly string[]): string {
|
|
56
|
+
const candidates = ['tlh', 'mi-NZ', 'eu-ES', 'kl-GL'];
|
|
57
|
+
return candidates.find((c) => !supportedLocales.includes(c)) ?? 'tlh';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function errCode(json: unknown): string | undefined {
|
|
61
|
+
const e = (json as { error?: unknown } | undefined)?.error;
|
|
62
|
+
return typeof e === 'string' ? e : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
describe.skipIf(HTTP_SKIP)('i18n-negotiation: advertisement shape (i18n.md §Capability advertisement)', () => {
|
|
66
|
+
it('the i18n block is either absent or well-formed', async () => {
|
|
67
|
+
const cap = await readCapabilityFamily<I18nCap>('i18n');
|
|
68
|
+
if (cap === undefined) return; // absent ⇒ single-locale host — conformant
|
|
69
|
+
|
|
70
|
+
expect(
|
|
71
|
+
typeof cap.supported,
|
|
72
|
+
driver.describe('capabilities.schema.json §i18n', 'i18n.supported MUST be a boolean when the block is present'),
|
|
73
|
+
).toBe('boolean');
|
|
74
|
+
|
|
75
|
+
if (cap.defaultLocale !== undefined) {
|
|
76
|
+
expect(
|
|
77
|
+
typeof cap.defaultLocale === 'string' && BCP47.test(cap.defaultLocale),
|
|
78
|
+
driver.describe('i18n.md §Capability advertisement', `i18n.defaultLocale MUST be a BCP 47 tag (got ${String(cap.defaultLocale)})`),
|
|
79
|
+
).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (cap.supportedLocales !== undefined) {
|
|
83
|
+
expect(
|
|
84
|
+
Array.isArray(cap.supportedLocales),
|
|
85
|
+
driver.describe('i18n.md §Capability advertisement', 'i18n.supportedLocales MUST be an array when present'),
|
|
86
|
+
).toBe(true);
|
|
87
|
+
const locales = (cap.supportedLocales as unknown[]) ?? [];
|
|
88
|
+
for (const tag of locales) {
|
|
89
|
+
expect(
|
|
90
|
+
typeof tag === 'string' && BCP47.test(tag),
|
|
91
|
+
driver.describe('i18n.md §Capability advertisement', `every supportedLocales entry MUST be a BCP 47 tag (got ${String(tag)})`),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
}
|
|
94
|
+
if (typeof cap.defaultLocale === 'string') {
|
|
95
|
+
expect(
|
|
96
|
+
locales,
|
|
97
|
+
driver.describe('i18n.md §Capability advertisement', 'supportedLocales MUST contain defaultLocale'),
|
|
98
|
+
).toContain(cap.defaultLocale);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe.skipIf(HTTP_SKIP)('i18n-negotiation: behavioral (i18n.md §Accept-Language + §Fallback rules)', () => {
|
|
105
|
+
it('an unsupported Accept-Language falls back (never 400) and Content-Language reflects the default', async () => {
|
|
106
|
+
const cap = await readCapabilityFamily<I18nCap>('i18n');
|
|
107
|
+
if (!behaviorGate('openwop-i18n', cap?.supported === true)) return;
|
|
108
|
+
|
|
109
|
+
const supported = Array.isArray(cap?.supportedLocales)
|
|
110
|
+
? (cap.supportedLocales as unknown[]).filter((t): t is string => typeof t === 'string')
|
|
111
|
+
: [];
|
|
112
|
+
const defaultLocale = typeof cap?.defaultLocale === 'string' ? cap.defaultLocale : 'en';
|
|
113
|
+
|
|
114
|
+
const res = await driver.get(PROBE_PATH, {
|
|
115
|
+
headers: { 'Accept-Language': pickUnsupportedTag(supported) },
|
|
116
|
+
});
|
|
117
|
+
expect(
|
|
118
|
+
res.status,
|
|
119
|
+
driver.describe(
|
|
120
|
+
'i18n.md §Accept-Language ("Host MUST NOT") + §Fallback rules',
|
|
121
|
+
'an unsupported Accept-Language MUST NOT fail the request — the route still answers 404 not_found, not 400/406',
|
|
122
|
+
),
|
|
123
|
+
).toBe(404);
|
|
124
|
+
|
|
125
|
+
const contentLanguage = res.headers.get('content-language');
|
|
126
|
+
if (contentLanguage !== null) {
|
|
127
|
+
expect(
|
|
128
|
+
BCP47.test(contentLanguage),
|
|
129
|
+
driver.describe('i18n.md §Accept-Language rule 4', `Content-Language MUST be a BCP 47 tag (got ${contentLanguage})`),
|
|
130
|
+
).toBe(true);
|
|
131
|
+
const primary = (tag: string): string => (tag.split('-')[0] ?? tag).toLowerCase();
|
|
132
|
+
expect(
|
|
133
|
+
primary(contentLanguage),
|
|
134
|
+
driver.describe(
|
|
135
|
+
'i18n.md §Fallback rules 3–4 + §Conformance',
|
|
136
|
+
'for an unsupported Accept-Language, Content-Language MUST reflect the host default locale (never lie)',
|
|
137
|
+
),
|
|
138
|
+
).toBe(primary(defaultLocale));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('a malformed Accept-Language MUST NOT cause 400', async () => {
|
|
143
|
+
const cap = await readCapabilityFamily<I18nCap>('i18n');
|
|
144
|
+
if (!behaviorGate('openwop-i18n', cap?.supported === true)) return;
|
|
145
|
+
|
|
146
|
+
const res = await driver.get(PROBE_PATH, {
|
|
147
|
+
headers: { 'Accept-Language': ';;;not===a,,language q=;' },
|
|
148
|
+
});
|
|
149
|
+
expect(
|
|
150
|
+
res.status,
|
|
151
|
+
driver.describe(
|
|
152
|
+
'i18n.md §Accept-Language Host-MUST rule 1',
|
|
153
|
+
'a malformed Accept-Language MUST NOT cause 400 — the host parses best-effort and proceeds (404 not_found here)',
|
|
154
|
+
),
|
|
155
|
+
).toBe(404);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('the error `code` stays the canonical English token under a negotiated locale', async () => {
|
|
159
|
+
const cap = await readCapabilityFamily<I18nCap>('i18n');
|
|
160
|
+
if (!behaviorGate('openwop-i18n', cap?.supported === true)) return;
|
|
161
|
+
|
|
162
|
+
const supported = Array.isArray(cap?.supportedLocales)
|
|
163
|
+
? (cap.supportedLocales as unknown[]).filter((t): t is string => typeof t === 'string')
|
|
164
|
+
: [];
|
|
165
|
+
const defaultLocale = typeof cap?.defaultLocale === 'string' ? cap.defaultLocale : 'en';
|
|
166
|
+
// Prefer a non-default supported locale so localization actually engages.
|
|
167
|
+
const negotiated = supported.find((t) => t !== defaultLocale) ?? defaultLocale;
|
|
168
|
+
|
|
169
|
+
const res = await driver.get(PROBE_PATH, {
|
|
170
|
+
headers: { 'Accept-Language': negotiated },
|
|
171
|
+
});
|
|
172
|
+
expect(res.status).toBe(404);
|
|
173
|
+
expect(
|
|
174
|
+
errCode(res.json),
|
|
175
|
+
driver.describe(
|
|
176
|
+
'i18n.md §"`locale` field on `ErrorEnvelope.details`"',
|
|
177
|
+
`the machine-readable error code MUST remain the canonical English token regardless of the negotiated locale (${negotiated})`,
|
|
178
|
+
),
|
|
179
|
+
).toBe('not_found');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* (NEVER 200 from a different run's scope).
|
|
21
21
|
*
|
|
22
22
|
* Gating identical to interrupt-external-event-correlation: skips when
|
|
23
|
-
* the `conformance-external-event` fixture isn't advertised.
|
|
23
|
+
* the `conformance-interrupt-external-event` fixture isn't advertised.
|
|
24
24
|
*
|
|
25
25
|
* @see spec/v1/interrupt.md §"Signed-token callback"
|
|
26
26
|
* @see spec/v1/rest-endpoints.md §"GET /v1/interrupts/{token}" + §"POST /v1/interrupts/{token}"
|
|
@@ -31,7 +31,7 @@ import { driver } from '../lib/driver.js';
|
|
|
31
31
|
import { pollUntilStatus } from '../lib/polling.js';
|
|
32
32
|
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
33
33
|
|
|
34
|
-
const FIXTURE = 'conformance-external-event';
|
|
34
|
+
const FIXTURE = 'conformance-interrupt-external-event';
|
|
35
35
|
const SKIP = !isFixtureAdvertised(FIXTURE);
|
|
36
36
|
|
|
37
37
|
function randomBytesB64(length: number): string {
|
|
@@ -14,9 +14,11 @@
|
|
|
14
14
|
* 4. When a host advertises `aiProviders.maxInlineMediaBytes`, it MUST be a
|
|
15
15
|
* non-negative integer.
|
|
16
16
|
*
|
|
17
|
-
* Behavioral (cross-tenant scoping + cap enforcement)
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Behavioral (cross-tenant scoping + cap enforcement) runs as real `it()`
|
|
18
|
+
* bodies against the reference host's media-asset seam
|
|
19
|
+
* (`POST /v1/host/sample/media/put` store + `GET /v1/host/sample/assets/{token}`
|
|
20
|
+
* serve) and soft-skips (early `return`) on hosts that don't expose the
|
|
21
|
+
* store seam — no `it.todo` staging remains in this file.
|
|
20
22
|
*
|
|
21
23
|
* @see RFCS/0055-multimodal-envelope-variants-and-rendering-hints.md §C
|
|
22
24
|
* @see spec/v1/ai-envelope.md §"Media reference payloads"
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reviewable learning — the proposal lifecycle (RFC 0096; `agent-memory.md`
|
|
3
|
+
* §"Reviewable learning"). Public tests for the protocol-tier SECURITY
|
|
4
|
+
* invariants `proposal-inert-until-applied` and `proposal-no-resynthesis`.
|
|
5
|
+
*
|
|
6
|
+
* Two layers:
|
|
7
|
+
*
|
|
8
|
+
* A. Always-on, server-free schema legs — the capability block, the
|
|
9
|
+
* `proposal.schema.json` shape (incl. the dropped `rule` kind), and the
|
|
10
|
+
* content-free `proposal.created` / `proposal.activated` event payloads.
|
|
11
|
+
*
|
|
12
|
+
* B. Capability-gated behavioral legs — on a host advertising
|
|
13
|
+
* `capabilities.agents.proposals` that exposes the
|
|
14
|
+
* `/v1/host/sample/proposals` seam: inertness (a draft proposal does not
|
|
15
|
+
* influence a run), gated activation (`apply` without scope → 403), and
|
|
16
|
+
* no-re-synthesis (installed artifact byte-matches the last-persisted
|
|
17
|
+
* `artifact`). Hosts without the seam soft-skip (404); unadvertised
|
|
18
|
+
* hosts skip via the behavior gate.
|
|
19
|
+
*
|
|
20
|
+
* @see spec/v1/agent-memory.md §"Reviewable learning"
|
|
21
|
+
* @see SECURITY/invariants.yaml id: proposal-inert-until-applied, proposal-no-resynthesis
|
|
22
|
+
* @see RFCS/0096-reviewable-learning-skill-proposal-lifecycle.md
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, expect } from 'vitest';
|
|
26
|
+
import { readFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
29
|
+
import addFormats from 'ajv-formats';
|
|
30
|
+
import { SCHEMAS_DIR } from '../lib/paths.js';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
33
|
+
import { readCapabilityFamily } from '../lib/discovery-capabilities.js';
|
|
34
|
+
|
|
35
|
+
const why = (specRef: string, requirement: string): string => `${specRef} — ${requirement}`;
|
|
36
|
+
function loadSchema(name: string): Record<string, unknown> {
|
|
37
|
+
return JSON.parse(readFileSync(join(SCHEMAS_DIR, name), 'utf8')) as Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('proposal-reviewable-learning: capability advertisement (RFC 0096 §A, server-free)', () => {
|
|
41
|
+
const caps = loadSchema('capabilities.schema.json');
|
|
42
|
+
const agents = (caps.properties as Record<string, { properties?: Record<string, { properties?: Record<string, unknown>; required?: string[] }> }>).agents;
|
|
43
|
+
|
|
44
|
+
it('capabilities schema declares agents.proposals with its required sub-flags', () => {
|
|
45
|
+
const proposals = agents?.properties?.proposals;
|
|
46
|
+
expect(proposals, why('capabilities.md §agents', 'agents.proposals MUST be declared')).toBeDefined();
|
|
47
|
+
for (const flag of ['artifactKinds', 'duplicationDetection', 'activation']) {
|
|
48
|
+
expect(proposals?.properties?.[flag], why('RFC 0096 §A', `agents.proposals.${flag} MUST be declared`)).toBeDefined();
|
|
49
|
+
}
|
|
50
|
+
expect(proposals?.required, why('RFC 0096 §A', 'artifactKinds + activation MUST be required')).toEqual(
|
|
51
|
+
expect.arrayContaining(['artifactKinds', 'activation']),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('the `rule` artifact kind is NOT in the enum (dropped — no defining RFC)', () => {
|
|
56
|
+
const kinds = (agents?.properties?.proposals as { properties?: Record<string, { items?: { enum?: string[] } }> })?.properties?.artifactKinds?.items?.enum ?? [];
|
|
57
|
+
expect(kinds, why('RFC 0096 §A', 'artifactKinds enumerates the four kinds with a defining RFC')).toEqual(
|
|
58
|
+
expect.arrayContaining(['agent-pack', 'workflow-chain-pack', 'prompt-template', 'automation']),
|
|
59
|
+
);
|
|
60
|
+
expect(kinds, why('RFC 0096 §A', '`rule` MUST NOT appear — it has no defining RFC, so its artifact is unvalidatable')).not.toContain('rule');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('proposal-reviewable-learning: Proposal shape (RFC 0096 §B, server-free)', () => {
|
|
65
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
66
|
+
addFormats(ajv);
|
|
67
|
+
const validate = ajv.compile(loadSchema('proposal.schema.json'));
|
|
68
|
+
|
|
69
|
+
const good = {
|
|
70
|
+
id: 'prop-1',
|
|
71
|
+
kind: 'workflow-chain-pack',
|
|
72
|
+
state: 'draft',
|
|
73
|
+
artifact: { name: 'weekly-digest', version: '1.0.0' },
|
|
74
|
+
provenance: { sourceRunIds: ['run-a', 'run-b'] },
|
|
75
|
+
owner: { tenant: 'acme' },
|
|
76
|
+
createdAt: '2026-06-13T00:00:00Z',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
it('validates a conforming draft proposal', () => {
|
|
80
|
+
expect(validate(good), why('RFC 0096 §B', `a conforming proposal MUST validate. Errors: ${JSON.stringify(validate.errors)}`)).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects an unknown state and the dropped `rule` kind', () => {
|
|
84
|
+
expect(validate({ ...good, state: 'live' }), why('RFC 0096 §B', 'a state outside the lifecycle enum MUST be rejected')).toBe(false);
|
|
85
|
+
expect(validate({ ...good, kind: 'rule' }), why('RFC 0096 §B', '`kind: "rule"` MUST be rejected (dropped from the enum)')).toBe(false);
|
|
86
|
+
expect(validate({ ...good, owner: { workspace: 'x' } }), why('RFC 0048', 'owner without tenant MUST be rejected')).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('proposal-reviewable-learning: content-free events (RFC 0096 §D, server-free)', () => {
|
|
91
|
+
const runEvent = loadSchema('run-event.schema.json');
|
|
92
|
+
const payloads = loadSchema('run-event-payloads.schema.json');
|
|
93
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true });
|
|
94
|
+
addFormats(ajv);
|
|
95
|
+
ajv.addSchema(payloads, 'payloads');
|
|
96
|
+
|
|
97
|
+
it('proposal.created and proposal.activated are in the RunEventType enum', () => {
|
|
98
|
+
const en = (runEvent.$defs as Record<string, { enum?: string[] }>).RunEventType?.enum ?? [];
|
|
99
|
+
expect(en).toContain('proposal.created');
|
|
100
|
+
expect(en).toContain('proposal.activated');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('proposal.created is content-free — an artifact body and rationale text are rejected', () => {
|
|
104
|
+
const created = ajv.getSchema('payloads#/$defs/proposalCreated')!;
|
|
105
|
+
expect(created({ proposalId: 'p1', kind: 'agent-pack', sourceRunIds: ['r1'], duplicateOf: null }), why('RFC 0096 §D', 'a content-free proposal.created MUST validate')).toBe(true);
|
|
106
|
+
expect(created({ proposalId: 'p1', kind: 'agent-pack', artifact: { x: 1 } }), why('SECURITY invariant proposal-inert-until-applied', 'proposal.created MUST NOT carry the artifact body')).toBe(false);
|
|
107
|
+
expect(created({ proposalId: 'p1', kind: 'agent-pack', rationale: 'because…' }), why('RFC 0096 §D', 'proposal.created MUST NOT carry rationale text')).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('proposal-reviewable-learning: behavioral (RFC 0096 §E, capability-gated)', () => {
|
|
112
|
+
it('apply without the activation scope is denied (403) and installs nothing', async () => {
|
|
113
|
+
const agents = await readCapabilityFamily<{ proposals?: { activation?: string } }>('agents');
|
|
114
|
+
if (!behaviorGate('agents.proposals', agents?.proposals !== undefined)) return;
|
|
115
|
+
|
|
116
|
+
const list = await driver.get('/v1/host/sample/proposals?state=draft');
|
|
117
|
+
if (list.status === 404 || list.status === 403) return; // seam unwired — soft-skip
|
|
118
|
+
const proposals = (list.json as { proposals?: Array<{ id: string }> })?.proposals ?? [];
|
|
119
|
+
if (proposals.length === 0) return; // nothing to act on — soft-skip
|
|
120
|
+
|
|
121
|
+
// Apply with no auth/scope — MUST be refused.
|
|
122
|
+
const res = await driver.post(`/v1/host/sample/proposals/${proposals[0]!.id}/apply`, {});
|
|
123
|
+
if (res.status === 404) return;
|
|
124
|
+
expect(
|
|
125
|
+
res.status,
|
|
126
|
+
driver.describe('agent-memory.md §"Reviewable learning" clause 2', 'apply without the activation scope MUST be denied (403)'),
|
|
127
|
+
).toBe(403);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -1552,3 +1552,110 @@ describe.skipIf(RFC0089_BUNDLE_PATH === null || !existsSync(RFC0089_BUNDLE_PATH)
|
|
|
1552
1552
|
expect(bundle.discovery.sha256).toBe(recomputed);
|
|
1553
1553
|
});
|
|
1554
1554
|
});
|
|
1555
|
+
|
|
1556
|
+
describe('spec-corpus: createRun composed request schema is satisfiable (RFC 0094 §A)', () => {
|
|
1557
|
+
// The 2026-06-11 corpus review found the published createRun requestBody
|
|
1558
|
+
// unsatisfiable: BOTH allOf branches (the inline request object and
|
|
1559
|
+
// run-options.schema.json) carried `additionalProperties: false`, so every
|
|
1560
|
+
// documented body failed one branch or the other. RFC 0094 §A moves the
|
|
1561
|
+
// closure to the composition site (`unevaluatedProperties: false`, JSON
|
|
1562
|
+
// Schema 2020-12) and opens both branches. These probes pin the repaired
|
|
1563
|
+
// contract so the defect class cannot silently return:
|
|
1564
|
+
// 1. (structural) the YAML composition closes at the composed level,
|
|
1565
|
+
// never at a branch;
|
|
1566
|
+
// 2. (semantic, ajv-2020) the canonical documented bodies PASS the
|
|
1567
|
+
// composition of the on-disk run-options.schema.json with the inline
|
|
1568
|
+
// branch's declared properties, while an undeclared property FAILS.
|
|
1569
|
+
const openapiPath = join(API_DIR, 'openapi.yaml');
|
|
1570
|
+
|
|
1571
|
+
function extractCreateRunRequestBlock(raw: string): string {
|
|
1572
|
+
const opStart = raw.indexOf('operationId: createRun');
|
|
1573
|
+
expect(opStart, 'OpenAPI MUST declare operationId createRun').toBeGreaterThanOrEqual(0);
|
|
1574
|
+
const bodyStart = raw.indexOf('requestBody:', opStart);
|
|
1575
|
+
const responsesStart = raw.indexOf('\n responses:', bodyStart);
|
|
1576
|
+
expect(bodyStart, 'createRun MUST declare a requestBody').toBeGreaterThan(opStart);
|
|
1577
|
+
expect(responsesStart, 'createRun requestBody MUST precede its responses').toBeGreaterThan(bodyStart);
|
|
1578
|
+
return raw.slice(bodyStart, responsesStart);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/** Property names declared on the inline (non-$ref) allOf branch of the
|
|
1582
|
+
* createRun requestBody — the 20-space-indented keys, minus JSON Schema
|
|
1583
|
+
* keywords that can appear at the same indent inside if/then/else. */
|
|
1584
|
+
function extractInlineBranchPropertyNames(block: string): string[] {
|
|
1585
|
+
const keywords = new Set([
|
|
1586
|
+
'type', 'properties', 'required', 'description', 'enum', 'format',
|
|
1587
|
+
'items', 'minLength', 'if', 'then', 'else', 'allOf', 'additionalProperties',
|
|
1588
|
+
'unevaluatedProperties',
|
|
1589
|
+
]);
|
|
1590
|
+
const names: string[] = [];
|
|
1591
|
+
const re = /^ {20}([A-Za-z][A-Za-z0-9]*):/gm;
|
|
1592
|
+
let m: RegExpExecArray | null;
|
|
1593
|
+
while ((m = re.exec(block)) !== null) {
|
|
1594
|
+
const name = m[1];
|
|
1595
|
+
if (name && !keywords.has(name) && !names.includes(name)) names.push(name);
|
|
1596
|
+
}
|
|
1597
|
+
return names;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
it('the requestBody closes at the composition (unevaluatedProperties), not inside a branch', () => {
|
|
1601
|
+
const { raw } = readYamlHeader(openapiPath);
|
|
1602
|
+
const block = extractCreateRunRequestBlock(raw);
|
|
1603
|
+
|
|
1604
|
+
expect(
|
|
1605
|
+
block,
|
|
1606
|
+
'RFC 0094 §A: the composed createRun request schema MUST be closed with `unevaluatedProperties: false`',
|
|
1607
|
+
).toContain('unevaluatedProperties: false');
|
|
1608
|
+
expect(
|
|
1609
|
+
block,
|
|
1610
|
+
'RFC 0094 §A: no allOf branch of the createRun requestBody may carry `additionalProperties: false` ' +
|
|
1611
|
+
'(a closed branch inside an allOf re-creates the unsatisfiable composition)',
|
|
1612
|
+
).not.toContain('additionalProperties: false');
|
|
1613
|
+
});
|
|
1614
|
+
|
|
1615
|
+
it('canonical createRun bodies PASS the composed schema; an undeclared property FAILS', () => {
|
|
1616
|
+
const { raw } = readYamlHeader(openapiPath);
|
|
1617
|
+
const inlineProps = extractInlineBranchPropertyNames(extractCreateRunRequestBlock(raw));
|
|
1618
|
+
expect(inlineProps, 'the inline branch MUST declare workflowId').toContain('workflowId');
|
|
1619
|
+
|
|
1620
|
+
// Compose exactly what RFC 0094 §A specifies: the inline branch's
|
|
1621
|
+
// declared properties + the REAL on-disk run-options.schema.json
|
|
1622
|
+
// (embedded with its $id so its internal #/$defs refs keep resolving),
|
|
1623
|
+
// closed at the composition with unevaluatedProperties.
|
|
1624
|
+
const runOptionsSchema = readJson(join(SCHEMAS_DIR, 'run-options.schema.json')) as Record<string, unknown>;
|
|
1625
|
+
delete runOptionsSchema['$schema']; // embedded subschema; the parent declares the dialect
|
|
1626
|
+
const composed = {
|
|
1627
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
1628
|
+
type: 'object',
|
|
1629
|
+
allOf: [
|
|
1630
|
+
{
|
|
1631
|
+
type: 'object',
|
|
1632
|
+
properties: Object.fromEntries(inlineProps.map((p) => [p, true])),
|
|
1633
|
+
},
|
|
1634
|
+
runOptionsSchema,
|
|
1635
|
+
],
|
|
1636
|
+
unevaluatedProperties: false,
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
const ajv = new Ajv2020({ allErrors: true, strict: false });
|
|
1640
|
+
addFormats(ajv);
|
|
1641
|
+
const validate = ajv.compile(composed);
|
|
1642
|
+
|
|
1643
|
+
const canonicalBodies: Array<Record<string, unknown>> = [
|
|
1644
|
+
{ workflowId: 'wf-1' },
|
|
1645
|
+
{ workflowId: 'wf-1', configurable: {} },
|
|
1646
|
+
{ workflowId: 'wf-1', inputs: {}, configurable: {}, tags: ['conformance'], metadata: {} },
|
|
1647
|
+
];
|
|
1648
|
+
for (const body of canonicalBodies) {
|
|
1649
|
+
expect(
|
|
1650
|
+
validate(body),
|
|
1651
|
+
`RFC 0094 §A: documented body ${JSON.stringify(body)} MUST satisfy the composed createRun ` +
|
|
1652
|
+
`request schema; ajv said: ${JSON.stringify(validate.errors)}`,
|
|
1653
|
+
).toBe(true);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
expect(
|
|
1657
|
+
validate({ workflowId: 'wf-1', definitelyNotASpecField: true }),
|
|
1658
|
+
'RFC 0094 §A: an undeclared property MUST still fail at the composed level (unevaluatedProperties: false)',
|
|
1659
|
+
).toBe(false);
|
|
1660
|
+
});
|
|
1661
|
+
});
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stream-text-fixture — `stream-modes.md` §`messages` end-to-end through the
|
|
3
|
+
* deterministic `stream-text` mock provider. Consumer of the
|
|
4
|
+
* `conformance-stream-text` fixture (closes catalog gap F1 — the fixture
|
|
5
|
+
* previously had no consuming scenario; see `fixtures.md`).
|
|
6
|
+
*
|
|
7
|
+
* Gating: `describe.skipIf` on the advertised `conformance-stream-text`
|
|
8
|
+
* fixture id (RFC 0003). Inside the test, a host that advertises the
|
|
9
|
+
* fixture but not the `stream-text` mock provider
|
|
10
|
+
* (`Capabilities.testing.mockProviders`) soft-skips with a warning —
|
|
11
|
+
* fixtures.md lets hosts mark this fixture optional until the
|
|
12
|
+
* mock-provider extension lands. `403 mock_provider_forbidden` (a
|
|
13
|
+
* production-scoped key) also soft-skips honestly.
|
|
14
|
+
*
|
|
15
|
+
* What's asserted per the fixture driver (`fixtures.md` §`conformance-stream-text`):
|
|
16
|
+
* 1. `?streamMode=messages` delivers the mocked tokens
|
|
17
|
+
* `["Hello", " ", "world", "!"]` as `ai.message.chunk` events in
|
|
18
|
+
* token order (the documented fold behavior).
|
|
19
|
+
* 2. The final chunk carries `isLast: true`,
|
|
20
|
+
* `meta.finishReason === "stop"`, and
|
|
21
|
+
* `meta.usage.completionTokens === 4` — the `{nodeId, runId, chunk,
|
|
22
|
+
* isLast}` minimum compliant payload per stream-modes.md §messages
|
|
23
|
+
* (single-sourced by RFC 0094 §D).
|
|
24
|
+
* 3. The SSE stream is server-closed on terminal (not timed out).
|
|
25
|
+
* 4. The run reaches terminal `completed`.
|
|
26
|
+
* 5. Negative: an unknown `mockProvider.id` is rejected with
|
|
27
|
+
* `400 unsupported_mock_provider`.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v1/stream-modes.md §`messages`
|
|
30
|
+
* @see conformance/fixtures.md §`conformance-stream-text`
|
|
31
|
+
* @see RFCS/0094-wire-shape-reconciliation.md §D
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect } from 'vitest';
|
|
35
|
+
import { driver, type OpenWOPResponse } from '../lib/driver.js';
|
|
36
|
+
import { subscribe } from '../lib/sse.js';
|
|
37
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
38
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
39
|
+
import { capabilityFamily } from '../lib/discovery-capabilities.js';
|
|
40
|
+
|
|
41
|
+
const FIXTURE_ID = 'conformance-stream-text';
|
|
42
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(FIXTURE_ID);
|
|
43
|
+
|
|
44
|
+
const MOCK_TOKENS = ['Hello', ' ', 'world', '!'];
|
|
45
|
+
|
|
46
|
+
interface ChunkPayload {
|
|
47
|
+
readonly nodeId?: unknown;
|
|
48
|
+
readonly runId?: unknown;
|
|
49
|
+
readonly chunk?: unknown;
|
|
50
|
+
readonly isLast?: unknown;
|
|
51
|
+
readonly meta?: {
|
|
52
|
+
readonly finishReason?: unknown;
|
|
53
|
+
readonly usage?: { readonly completionTokens?: unknown };
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function errCode(json: unknown): string | undefined {
|
|
58
|
+
const e = (json as { error?: unknown } | undefined)?.error;
|
|
59
|
+
return typeof e === 'string' ? e : undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function createMockRun(mockProviderId: string): Promise<OpenWOPResponse> {
|
|
63
|
+
return driver.post('/v1/runs', {
|
|
64
|
+
workflowId: FIXTURE_ID,
|
|
65
|
+
configurable: {
|
|
66
|
+
mockProvider: {
|
|
67
|
+
id: mockProviderId,
|
|
68
|
+
config: {
|
|
69
|
+
tokens: MOCK_TOKENS,
|
|
70
|
+
delayMsPerToken: 10,
|
|
71
|
+
finishReason: 'stop',
|
|
72
|
+
usage: { promptTokens: 5, completionTokens: 4, totalTokens: 9 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when the host advertises the v1-mandatory `stream-text` mock
|
|
80
|
+
* provider in `Capabilities.testing.mockProviders`. */
|
|
81
|
+
async function isStreamTextMockAdvertised(): Promise<boolean> {
|
|
82
|
+
const res = await driver.get('/.well-known/openwop');
|
|
83
|
+
if (res.status !== 200) return false;
|
|
84
|
+
const testing = capabilityFamily<{ mockProviders?: unknown }>(res.json, 'testing');
|
|
85
|
+
return Array.isArray(testing?.mockProviders) && testing.mockProviders.includes('stream-text');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe.skipIf(SKIP_NO_FIXTURE)('stream-text-fixture: messages-mode fold through the stream-text mock (F1)', () => {
|
|
89
|
+
it('delivers the mocked tokens in order, terminates with isLast + meta, and server-closes', async () => {
|
|
90
|
+
if (!(await isStreamTextMockAdvertised())) {
|
|
91
|
+
// eslint-disable-next-line no-console
|
|
92
|
+
console.warn(
|
|
93
|
+
'[stream-text-fixture] host does not advertise the stream-text mock provider ' +
|
|
94
|
+
'(Capabilities.testing.mockProviders); skipping',
|
|
95
|
+
);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const create = await createMockRun('stream-text');
|
|
100
|
+
if (create.status === 403 && errCode(create.json) === 'mock_provider_forbidden') {
|
|
101
|
+
// eslint-disable-next-line no-console
|
|
102
|
+
console.warn('[stream-text-fixture] API key is production-scoped (403 mock_provider_forbidden); skipping');
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
expect(
|
|
106
|
+
create.status,
|
|
107
|
+
driver.describe(
|
|
108
|
+
'fixtures.md §conformance-stream-text',
|
|
109
|
+
'a test key MUST be able to start the fixture with configurable.mockProvider',
|
|
110
|
+
),
|
|
111
|
+
).toBe(201);
|
|
112
|
+
const runId = (create.json as { runId: string }).runId;
|
|
113
|
+
|
|
114
|
+
const result = await subscribe(
|
|
115
|
+
`/v1/runs/${encodeURIComponent(runId)}/events?streamMode=messages`,
|
|
116
|
+
{ timeoutMs: 15_000 },
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// 3) server-closed, not timeout.
|
|
120
|
+
expect(
|
|
121
|
+
result.closedBy,
|
|
122
|
+
driver.describe('stream-modes.md §messages', 'server MUST close the messages stream on terminal run status'),
|
|
123
|
+
).toBe('server');
|
|
124
|
+
|
|
125
|
+
// Collect ai.message.chunk payloads. Per stream-modes.md the SSE event
|
|
126
|
+
// type IS ai.message.chunk; tolerate hosts that wrap the payload under
|
|
127
|
+
// a `payload` envelope (the RunEventDoc shape).
|
|
128
|
+
const chunks: ChunkPayload[] = [];
|
|
129
|
+
for (const e of result.events) {
|
|
130
|
+
if (e.event !== 'ai.message.chunk') continue;
|
|
131
|
+
let parsed: unknown;
|
|
132
|
+
try {
|
|
133
|
+
parsed = JSON.parse(e.data);
|
|
134
|
+
} catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const direct = parsed as ChunkPayload & { payload?: ChunkPayload };
|
|
138
|
+
chunks.push(typeof direct.chunk === 'string' ? direct : (direct.payload ?? direct));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
chunks.length,
|
|
143
|
+
driver.describe(
|
|
144
|
+
'stream-modes.md §messages',
|
|
145
|
+
'messages mode MUST emit ai.message.chunk SSE events for the streaming AI node',
|
|
146
|
+
),
|
|
147
|
+
).toBe(MOCK_TOKENS.length);
|
|
148
|
+
|
|
149
|
+
// 1) Token order matches the mock config exactly.
|
|
150
|
+
expect(
|
|
151
|
+
chunks.map((c) => c.chunk),
|
|
152
|
+
driver.describe(
|
|
153
|
+
'fixtures.md §conformance-stream-text',
|
|
154
|
+
'chunk arrival order MUST match the mock token order',
|
|
155
|
+
),
|
|
156
|
+
).toEqual(MOCK_TOKENS);
|
|
157
|
+
|
|
158
|
+
// 2) Final-chunk contract: isLast + Tier 1 meta.
|
|
159
|
+
const last = chunks[chunks.length - 1]!;
|
|
160
|
+
expect(
|
|
161
|
+
last.isLast,
|
|
162
|
+
driver.describe(
|
|
163
|
+
'stream-modes.md §messages + RFC 0094 §D',
|
|
164
|
+
'the final ai.message.chunk MUST carry isLast: true',
|
|
165
|
+
),
|
|
166
|
+
).toBe(true);
|
|
167
|
+
expect(
|
|
168
|
+
last.meta?.finishReason,
|
|
169
|
+
driver.describe('stream-modes.md §messages (Tier 1)', 'final chunk meta.finishReason MUST be "stop"'),
|
|
170
|
+
).toBe('stop');
|
|
171
|
+
expect(
|
|
172
|
+
last.meta?.usage?.completionTokens,
|
|
173
|
+
driver.describe('stream-modes.md §messages (Tier 1)', 'final chunk meta.usage.completionTokens MUST echo the mock usage'),
|
|
174
|
+
).toBe(4);
|
|
175
|
+
|
|
176
|
+
// Minimum compliant payload fields are present on every chunk.
|
|
177
|
+
for (const c of chunks) {
|
|
178
|
+
expect(typeof c.nodeId, driver.describe('run-event-payloads.schema.json §outputChunk', 'nodeId MUST be a string')).toBe('string');
|
|
179
|
+
expect(typeof c.runId, driver.describe('run-event-payloads.schema.json §outputChunk', 'runId MUST be a string')).toBe('string');
|
|
180
|
+
expect(typeof c.isLast, driver.describe('run-event-payloads.schema.json §outputChunk', 'isLast MUST be a boolean')).toBe('boolean');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 4) Terminal completed.
|
|
184
|
+
const terminal = await pollUntilTerminal(runId);
|
|
185
|
+
expect(
|
|
186
|
+
terminal.status,
|
|
187
|
+
driver.describe('fixtures.md §conformance-stream-text', 'the fixture run MUST reach terminal completed'),
|
|
188
|
+
).toBe('completed');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('an unknown mockProvider.id is rejected with 400 unsupported_mock_provider', async () => {
|
|
192
|
+
if (!(await isStreamTextMockAdvertised())) {
|
|
193
|
+
// eslint-disable-next-line no-console
|
|
194
|
+
console.warn('[stream-text-fixture] stream-text mock not advertised; skipping negative leg');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const create = await createMockRun('does-not-exist');
|
|
198
|
+
if (create.status === 403 && errCode(create.json) === 'mock_provider_forbidden') {
|
|
199
|
+
// eslint-disable-next-line no-console
|
|
200
|
+
console.warn('[stream-text-fixture] API key is production-scoped; skipping negative leg');
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
expect(
|
|
204
|
+
create.status,
|
|
205
|
+
driver.describe('fixtures.md §conformance-stream-text', 'an unknown mockProvider.id MUST be rejected with 400'),
|
|
206
|
+
).toBe(400);
|
|
207
|
+
expect(
|
|
208
|
+
errCode(create.json),
|
|
209
|
+
driver.describe('fixtures.md §conformance-stream-text', 'the refusal MUST carry the canonical unsupported_mock_provider code'),
|
|
210
|
+
).toBe('unsupported_mock_provider');
|
|
211
|
+
});
|
|
212
|
+
});
|