@openwop/openwop-conformance 1.1.1 → 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 +90 -0
- package/README.md +2 -2
- package/api/redocly.yaml +15 -0
- package/coverage.md +27 -14
- package/fixtures/conformance-agent-low-confidence.json +7 -4
- package/fixtures/conformance-agent-pack-handoff-schema-validation.json +30 -0
- package/fixtures/conformance-agent-reasoning-streaming.json +37 -0
- package/fixtures/conformance-agent-reasoning.json +23 -4
- package/fixtures/conformance-dispatch-cancellable-child.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-a.json +27 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff-child-b.json +25 -0
- package/fixtures/conformance-dispatch-cross-worker-handoff.json +60 -0
- package/fixtures/conformance-dispatch-deterministic-fail-child.json +30 -0
- package/fixtures/conformance-dispatch-input-mapping-child.json +25 -0
- package/fixtures/conformance-dispatch-input-mapping-no-default.json +49 -0
- package/fixtures/conformance-dispatch-input-mapping.json +49 -0
- package/fixtures/conformance-dispatch-output-mapping-child.json +27 -0
- package/fixtures/conformance-dispatch-output-mapping.json +49 -0
- package/fixtures/conformance-dispatch-per-worker-override.json +59 -0
- package/fixtures/conformance-subworkflow-input-mapping-child.json +27 -0
- package/fixtures/conformance-subworkflow-input-mapping-no-default.json +33 -0
- package/fixtures/conformance-subworkflow-input-mapping.json +33 -0
- package/fixtures.md +18 -2
- package/package.json +1 -1
- package/schemas/README.md +7 -0
- package/schemas/agent-ref.schema.json +1 -1
- package/schemas/ai-envelope.schema.json +106 -0
- package/schemas/capabilities.schema.json +264 -0
- package/schemas/core-conformance-mock-agent-config.schema.json +152 -0
- package/schemas/dispatch-config.schema.json +26 -0
- package/schemas/envelopes/clarification.request.schema.json +43 -0
- package/schemas/envelopes/error.schema.json +26 -0
- package/schemas/envelopes/schema.request.schema.json +22 -0
- package/schemas/envelopes/schema.response.schema.json +22 -0
- package/schemas/node-pack-manifest.schema.json +5 -0
- package/schemas/pack-lockfile.schema.json +16 -0
- package/schemas/run-event-payloads.schema.json +35 -1
- package/schemas/run-event.schema.json +2 -0
- package/schemas/workflow-chain-pack-manifest.schema.json +226 -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/lib/webhook-receiver.ts +137 -0
- package/src/lib/workflow-chain-expansion.ts +213 -0
- package/src/scenarios/agentPackCatalog.test.ts +216 -0
- package/src/scenarios/agentPackHandoffSchemaValidation.test.ts +146 -0
- package/src/scenarios/agentReasoningEvents.test.ts +58 -7
- package/src/scenarios/agentReasoningStreaming.test.ts +193 -0
- package/src/scenarios/agents-run-tool-allowlist.test.ts +182 -0
- package/src/scenarios/ai-envelope-shape.test.ts +362 -0
- package/src/scenarios/aiEnvelope.capBreached.test.ts +261 -0
- package/src/scenarios/aiEnvelope.contractRefusal.test.ts +268 -0
- package/src/scenarios/aiEnvelope.correlationReplay.test.ts +284 -0
- package/src/scenarios/aiEnvelope.redaction.test.ts +253 -0
- package/src/scenarios/aiEnvelope.schemaDrift.test.ts +226 -0
- package/src/scenarios/aiEnvelope.trustBoundaryPropagation.test.ts +194 -0
- package/src/scenarios/aiEnvelope.universalKinds.test.ts +267 -0
- package/src/scenarios/append-ordering.test.ts +44 -0
- package/src/scenarios/artifact-auth.test.ts +58 -0
- package/src/scenarios/blob-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/blob-presign-expiry.test.ts +99 -0
- package/src/scenarios/blob-roundtrip.test.ts +0 -0
- package/src/scenarios/cache-cross-tenant-isolation.test.ts +61 -0
- package/src/scenarios/cache-ttl-expiry.test.ts +73 -0
- package/src/scenarios/dispatch-cross-worker-handoff.test.ts +129 -0
- package/src/scenarios/dispatch-input-mapping.test.ts +163 -0
- package/src/scenarios/dispatch-output-mapping.test.ts +155 -0
- package/src/scenarios/fixtures-gating.test.ts +139 -1
- package/src/scenarios/fs-path-traversal.test.ts +124 -0
- package/src/scenarios/idempotency-key-determinism.test.ts +230 -0
- package/src/scenarios/interrupt-token-matrix.test.ts +126 -0
- package/src/scenarios/kv-atomic-increment.test.ts +74 -0
- package/src/scenarios/kv-cas.test.ts +75 -0
- package/src/scenarios/kv-cross-tenant-isolation.test.ts +85 -0
- package/src/scenarios/kv-ttl-expiry.test.ts +78 -0
- package/src/scenarios/mcp-server-elicitation-bridge.test.ts +92 -0
- package/src/scenarios/mcp-server-prompt-roundtrip.test.ts +80 -0
- package/src/scenarios/mcp-server-resource-roundtrip.test.ts +82 -0
- package/src/scenarios/mcp-server-sampling-bridge.test.ts +84 -0
- package/src/scenarios/mcp-server-tool-roundtrip.test.ts +107 -0
- package/src/scenarios/mcp-server-untrusted-args.test.ts +105 -0
- package/src/scenarios/otel-trace-propagation-subworkflow.test.ts +19 -0
- package/src/scenarios/pack-registry-publish.test.ts +231 -51
- package/src/scenarios/pause-resume.test.ts +43 -0
- package/src/scenarios/provider-usage.test.ts +185 -0
- package/src/scenarios/queue-ack-nack-dlq.test.ts +121 -0
- package/src/scenarios/queue-cross-tenant-isolation.test.ts +66 -0
- package/src/scenarios/queue-publish-consume-roundtrip.test.ts +88 -0
- package/src/scenarios/replay-llm-cache-key.test.ts +166 -25
- package/src/scenarios/search-bm25-roundtrip.test.ts +92 -0
- package/src/scenarios/spec-corpus-validity.test.ts +17 -1
- package/src/scenarios/sql-injection-rejection.test.ts +84 -0
- package/src/scenarios/sql-transaction-atomicity.test.ts +95 -0
- package/src/scenarios/stream-subscribe-from-beginning.test.ts +103 -0
- package/src/scenarios/subworkflow-input-mapping.test.ts +170 -0
- package/src/scenarios/table-cross-tenant-isolation.test.ts +65 -0
- package/src/scenarios/table-cursor-pagination.test.ts +85 -0
- package/src/scenarios/table-schema-enforcement.test.ts +84 -0
- package/src/scenarios/vector-knn-roundtrip.test.ts +88 -0
- package/src/scenarios/webhook-receiver-adversarial.test.ts +210 -0
- package/src/scenarios/workflow-chain-expansion.test.ts +366 -0
- package/src/scenarios/workflow-chain-host-expansion.test.ts +202 -0
- package/src/scenarios/workflow-chain-pack-manifest-validation.test.ts +232 -0
- package/src/scenarios/workflow-chain-pack-signature-verification.test.ts +138 -0
- package/src/scenarios/workflow-chain-unresolvable-typeid.test.ts +170 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-chain expansion semantics — `workflow-chain-packs.md` §"Expansion semantics (normative)"
|
|
3
|
+
* + reference impl at `conformance/src/lib/workflow-chain-expansion.ts` (closes RFC 0013 Phase 3).
|
|
4
|
+
*
|
|
5
|
+
* Server-free scenario. Exercises the 9-step expansion algorithm
|
|
6
|
+
* through the reference library. Asserts:
|
|
7
|
+
*
|
|
8
|
+
* - Step 5: `{{params.<name>}}` placeholders substitute literally,
|
|
9
|
+
* recursively into nested string fields of `config` / `inputs`.
|
|
10
|
+
* - Step 6: node id rewriting — same chainId expanded TWICE in one
|
|
11
|
+
* parent workflow MUST produce non-colliding ids (the load-bearing
|
|
12
|
+
* property — without it, multi-expansion DAGs would have id
|
|
13
|
+
* duplicates that violate workflow-definition.schema.json).
|
|
14
|
+
* - Step 8: capability propagation — chain-level `capabilities[]`
|
|
15
|
+
* copy into every expanded node's `capabilities[]` so existing
|
|
16
|
+
* side-effect / streamable gates apply uniformly post-expansion.
|
|
17
|
+
* - Edge rewriting — `from`/`to` endpoints that reference fragment
|
|
18
|
+
* nodes get the chain prefix; refs to nodes OUTSIDE the fragment
|
|
19
|
+
* pass through unchanged so the parent workflow's adjacent wiring
|
|
20
|
+
* stays intact.
|
|
21
|
+
* - Output shape — the expanded fragment contains ONLY concrete
|
|
22
|
+
* typeIds (no chain reference survives to runtime).
|
|
23
|
+
*
|
|
24
|
+
* Capability-gated (logical sense): a host that ships chain expansion
|
|
25
|
+
* advertises `capabilities.workflowChainPacks.supported: true`. This
|
|
26
|
+
* scenario runs unconditionally because the reference library is
|
|
27
|
+
* black-box logic — the spec is the spec regardless of host adoption.
|
|
28
|
+
* Host-side end-to-end expansion gates on the capability flag.
|
|
29
|
+
*
|
|
30
|
+
* @see spec/v1/workflow-chain-packs.md §"Expansion semantics (normative)"
|
|
31
|
+
* @see conformance/src/lib/workflow-chain-expansion.ts
|
|
32
|
+
* @see RFCS/0013-workflow-chain-packs.md
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { describe, it, expect } from 'vitest';
|
|
36
|
+
import { expandChain, type WorkflowChain } from '../lib/workflow-chain-expansion.js';
|
|
37
|
+
|
|
38
|
+
/** Index helper that narrows away the `T | undefined` from `noUncheckedIndexedAccess`
|
|
39
|
+
* with an actionable error message — replaces `arr[i]!` non-null assertions so a
|
|
40
|
+
* shape mismatch surfaces as a real diagnostic instead of a TypeError-on-property-access. */
|
|
41
|
+
function at<T>(arr: ReadonlyArray<T>, i: number, label: string): T {
|
|
42
|
+
const value = arr[i];
|
|
43
|
+
if (value === undefined) {
|
|
44
|
+
throw new Error(`${label}[${i}] expected to be defined (length=${arr.length})`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Canonical sample chain used across multiple cases — mirrors the
|
|
50
|
+
* spec doc's "Positive: 1-node chain" example, kept tight so each
|
|
51
|
+
* assertion is focused on one rule. */
|
|
52
|
+
const SAMPLE_CHAIN: WorkflowChain = {
|
|
53
|
+
chainId: 'vendor.acme.generatePRD',
|
|
54
|
+
version: '1.0.0',
|
|
55
|
+
label: 'Generate PRD',
|
|
56
|
+
description: 'Single-node AI call.',
|
|
57
|
+
parameters: {
|
|
58
|
+
type: 'object',
|
|
59
|
+
required: ['productIdea'],
|
|
60
|
+
properties: {
|
|
61
|
+
productIdea: { type: 'string' },
|
|
62
|
+
targetAudience: { type: 'string', default: '' },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
dag: {
|
|
66
|
+
nodes: [
|
|
67
|
+
{
|
|
68
|
+
id: 'prd-call',
|
|
69
|
+
typeId: 'core.ai.callPrompt',
|
|
70
|
+
name: 'Generate PRD',
|
|
71
|
+
config: {
|
|
72
|
+
systemPrompt: 'Write a PRD for: {{params.productIdea}}\nAudience: {{params.targetAudience}}',
|
|
73
|
+
envelopeType: 'prd.create',
|
|
74
|
+
provider: 'anthropic',
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
edges: [],
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** Resolver helper — accepts every typeId. Used when typeId resolution
|
|
83
|
+
* isn't the property under test. The unresolvable-typeid scenario lives
|
|
84
|
+
* in its own file. */
|
|
85
|
+
const RESOLVE_ALL = (_typeId: string): boolean => true;
|
|
86
|
+
|
|
87
|
+
describe('category: workflow-chain expansion — placeholder substitution', () => {
|
|
88
|
+
it('substitutes {{params.x}} placeholders literally in config strings', () => {
|
|
89
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
90
|
+
expansionId: 'a8f3',
|
|
91
|
+
params: { productIdea: 'AI-powered toaster', targetAudience: 'restaurant chains' },
|
|
92
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
93
|
+
});
|
|
94
|
+
expect(fragment.nodes).toHaveLength(1);
|
|
95
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as { systemPrompt: string };
|
|
96
|
+
expect(
|
|
97
|
+
config.systemPrompt,
|
|
98
|
+
'Per workflow-chain-packs.md §"Parameter substitution": placeholders MUST be substituted literally at expansion time.',
|
|
99
|
+
).toBe('Write a PRD for: AI-powered toaster\nAudience: restaurant chains');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('recurses into nested object structures in config', () => {
|
|
103
|
+
const chain: WorkflowChain = {
|
|
104
|
+
...SAMPLE_CHAIN,
|
|
105
|
+
dag: {
|
|
106
|
+
nodes: [
|
|
107
|
+
{
|
|
108
|
+
id: 'n',
|
|
109
|
+
typeId: 'core.identity',
|
|
110
|
+
config: {
|
|
111
|
+
outer: {
|
|
112
|
+
middle: {
|
|
113
|
+
message: 'Hello {{params.who}}',
|
|
114
|
+
tags: ['static', '{{params.who}}-tag'],
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
],
|
|
120
|
+
edges: [],
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
const fragment = expandChain(chain, {
|
|
124
|
+
expansionId: 'x1',
|
|
125
|
+
params: { who: 'world' },
|
|
126
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
127
|
+
});
|
|
128
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as {
|
|
129
|
+
outer: { middle: { message: string; tags: string[] } };
|
|
130
|
+
};
|
|
131
|
+
expect(
|
|
132
|
+
config.outer.middle.message,
|
|
133
|
+
'Recursive substitution MUST walk into nested objects (per spec §Parameter substitution: "Substitution MUST recurse into nested string values within `config` and `inputs`").',
|
|
134
|
+
).toBe('Hello world');
|
|
135
|
+
expect(
|
|
136
|
+
config.outer.middle.tags,
|
|
137
|
+
'Recursive substitution MUST also walk into array elements when those elements are strings.',
|
|
138
|
+
).toEqual(['static', 'world-tag']);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('leaves non-placeholder strings untouched', () => {
|
|
142
|
+
const fragment = expandChain(
|
|
143
|
+
{
|
|
144
|
+
...SAMPLE_CHAIN,
|
|
145
|
+
dag: {
|
|
146
|
+
nodes: [
|
|
147
|
+
{
|
|
148
|
+
id: 'n',
|
|
149
|
+
typeId: 'core.identity',
|
|
150
|
+
config: { literal: 'no placeholders here', empty: '' },
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
edges: [],
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
{ expansionId: 'x', params: {}, isTypeIdResolvable: RESOLVE_ALL },
|
|
157
|
+
);
|
|
158
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as { literal: string; empty: string };
|
|
159
|
+
expect(config.literal).toBe('no placeholders here');
|
|
160
|
+
expect(config.empty).toBe('');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('substitutes the same placeholder name in multiple positions', () => {
|
|
164
|
+
const fragment = expandChain(
|
|
165
|
+
{
|
|
166
|
+
...SAMPLE_CHAIN,
|
|
167
|
+
dag: {
|
|
168
|
+
nodes: [
|
|
169
|
+
{
|
|
170
|
+
id: 'n',
|
|
171
|
+
typeId: 'core.identity',
|
|
172
|
+
config: { a: '{{params.who}}', b: '{{params.who}}', joined: 'hi {{params.who}}, hi again {{params.who}}' },
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
edges: [],
|
|
176
|
+
},
|
|
177
|
+
},
|
|
178
|
+
{ expansionId: 'x', params: { who: 'Ada' }, isTypeIdResolvable: RESOLVE_ALL },
|
|
179
|
+
);
|
|
180
|
+
const config = at(fragment.nodes, 0, 'fragment.nodes').config as { a: string; b: string; joined: string };
|
|
181
|
+
expect(config.a).toBe('Ada');
|
|
182
|
+
expect(config.b).toBe('Ada');
|
|
183
|
+
expect(config.joined).toBe('hi Ada, hi again Ada');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('category: workflow-chain expansion — node id collision avoidance', () => {
|
|
188
|
+
it('same chain expanded TWICE in one parent workflow produces non-colliding node ids', () => {
|
|
189
|
+
const first = expandChain(SAMPLE_CHAIN, {
|
|
190
|
+
expansionId: 'a8f3',
|
|
191
|
+
params: { productIdea: 'first', targetAudience: '' },
|
|
192
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
193
|
+
});
|
|
194
|
+
const second = expandChain(SAMPLE_CHAIN, {
|
|
195
|
+
expansionId: 'b9e4',
|
|
196
|
+
params: { productIdea: 'second', targetAudience: '' },
|
|
197
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
198
|
+
});
|
|
199
|
+
expect(
|
|
200
|
+
at(first.nodes, 0, 'first.nodes').id,
|
|
201
|
+
'Per spec §"Expansion semantics" step 6: each expansion MUST use a unique `expansionId` prefix so multi-expansion DAGs do not collide.',
|
|
202
|
+
).not.toBe(at(second.nodes, 0, 'second.nodes').id);
|
|
203
|
+
expect(at(first.nodes, 0, 'first.nodes').id).toBe('vendor_acme_generatePRD_a8f3_prd-call');
|
|
204
|
+
expect(at(second.nodes, 0, 'second.nodes').id).toBe('vendor_acme_generatePRD_b9e4_prd-call');
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rewritten ids replace dots in chainId with underscores for storage-key safety', () => {
|
|
208
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
209
|
+
expansionId: 'x',
|
|
210
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
211
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
212
|
+
});
|
|
213
|
+
expect(
|
|
214
|
+
at(fragment.nodes, 0, 'fragment.nodes').id,
|
|
215
|
+
'chainId `vendor.acme.generatePRD` MUST be slugged (dots → underscores) so the resulting id is safe to use in storage backends that reserve `.` for hierarchical keys.',
|
|
216
|
+
).toMatch(/^vendor_acme_generatePRD_/);
|
|
217
|
+
expect(at(fragment.nodes, 0, 'fragment.nodes').id).not.toMatch(/\./);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('exposes idMap so the caller can wire parent-workflow edges into the expansion', () => {
|
|
221
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
222
|
+
expansionId: 'a8f3',
|
|
223
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
224
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
225
|
+
});
|
|
226
|
+
expect(
|
|
227
|
+
fragment.idMap.get('prd-call'),
|
|
228
|
+
'The expansion contract MUST surface the original-id → rewritten-id map so the caller can route adjacent parent-workflow edges to the right expanded node.',
|
|
229
|
+
).toBe('vendor_acme_generatePRD_a8f3_prd-call');
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('category: workflow-chain expansion — edge rewriting', () => {
|
|
234
|
+
const MULTI_NODE: WorkflowChain = {
|
|
235
|
+
chainId: 'vendor.acme.twoStep',
|
|
236
|
+
version: '1.0.0',
|
|
237
|
+
label: 'Two-step',
|
|
238
|
+
description: 'Two nodes connected by an edge.',
|
|
239
|
+
parameters: {},
|
|
240
|
+
dag: {
|
|
241
|
+
nodes: [
|
|
242
|
+
{ id: 'first', typeId: 'core.identity', config: {} },
|
|
243
|
+
{ id: 'second', typeId: 'core.identity', config: {} },
|
|
244
|
+
],
|
|
245
|
+
edges: [{ from: 'first', to: 'second' }],
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
it('rewrites `from` and `to` ids that reference fragment nodes', () => {
|
|
250
|
+
const fragment = expandChain(MULTI_NODE, {
|
|
251
|
+
expansionId: 'e1',
|
|
252
|
+
params: {},
|
|
253
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
254
|
+
});
|
|
255
|
+
expect(fragment.edges).toHaveLength(1);
|
|
256
|
+
expect(
|
|
257
|
+
at(fragment.edges, 0, 'fragment.edges').from,
|
|
258
|
+
'Edge `from` ids that match fragment node ids MUST be rewritten with the same prefix as the nodes themselves.',
|
|
259
|
+
).toBe('vendor_acme_twoStep_e1_first');
|
|
260
|
+
expect(at(fragment.edges, 0, 'fragment.edges').to).toBe('vendor_acme_twoStep_e1_second');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('preserves port-name suffix (`nodeId.portName`) when rewriting edge refs', () => {
|
|
264
|
+
const withPorts: WorkflowChain = {
|
|
265
|
+
...MULTI_NODE,
|
|
266
|
+
dag: {
|
|
267
|
+
...MULTI_NODE.dag,
|
|
268
|
+
edges: [{ from: 'first.out', to: 'second.in' }],
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
const fragment = expandChain(withPorts, {
|
|
272
|
+
expansionId: 'e2',
|
|
273
|
+
params: {},
|
|
274
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
275
|
+
});
|
|
276
|
+
expect(
|
|
277
|
+
at(fragment.edges, 0, 'fragment.edges').from,
|
|
278
|
+
'Port-name suffix (after the `.`) MUST be preserved verbatim during id rewriting.',
|
|
279
|
+
).toBe('vendor_acme_twoStep_e2_first.out');
|
|
280
|
+
expect(at(fragment.edges, 0, 'fragment.edges').to).toBe('vendor_acme_twoStep_e2_second.in');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('leaves edge refs alone when they don\'t match a fragment node id', () => {
|
|
284
|
+
// Refs to OUTSIDE the fragment let the parent-workflow host splice
|
|
285
|
+
// in adjacent wiring. Critical for the "this chain plugs into
|
|
286
|
+
// node-X-of-parent-workflow" use case.
|
|
287
|
+
const withExternalRefs: WorkflowChain = {
|
|
288
|
+
...MULTI_NODE,
|
|
289
|
+
dag: {
|
|
290
|
+
...MULTI_NODE.dag,
|
|
291
|
+
edges: [
|
|
292
|
+
{ from: 'parent-upstream', to: 'first' },
|
|
293
|
+
{ from: 'second', to: 'parent-downstream' },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
const fragment = expandChain(withExternalRefs, {
|
|
298
|
+
expansionId: 'e3',
|
|
299
|
+
params: {},
|
|
300
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
301
|
+
});
|
|
302
|
+
expect(
|
|
303
|
+
at(fragment.edges, 0, 'fragment.edges').from,
|
|
304
|
+
'Edge refs to nodes OUTSIDE the fragment MUST pass through unchanged so the parent host can wire adjacent edges post-splice.',
|
|
305
|
+
).toBe('parent-upstream');
|
|
306
|
+
expect(at(fragment.edges, 0, 'fragment.edges').to).toBe('vendor_acme_twoStep_e3_first');
|
|
307
|
+
expect(at(fragment.edges, 1, 'fragment.edges').from).toBe('vendor_acme_twoStep_e3_second');
|
|
308
|
+
expect(at(fragment.edges, 1, 'fragment.edges').to).toBe('parent-downstream');
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe('category: workflow-chain expansion — capability propagation', () => {
|
|
313
|
+
it('copies chain.capabilities[] into every expanded node\'s capabilities[]', () => {
|
|
314
|
+
const chainWithCaps: WorkflowChain = {
|
|
315
|
+
...SAMPLE_CHAIN,
|
|
316
|
+
capabilities: ['side-effectful', 'cacheable'],
|
|
317
|
+
dag: {
|
|
318
|
+
nodes: [
|
|
319
|
+
{ id: 'n1', typeId: 'core.identity', config: {} },
|
|
320
|
+
{ id: 'n2', typeId: 'core.identity', config: {} },
|
|
321
|
+
],
|
|
322
|
+
edges: [{ from: 'n1', to: 'n2' }],
|
|
323
|
+
},
|
|
324
|
+
};
|
|
325
|
+
const fragment = expandChain(chainWithCaps, {
|
|
326
|
+
expansionId: 'c1',
|
|
327
|
+
params: {},
|
|
328
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
329
|
+
});
|
|
330
|
+
for (const node of fragment.nodes) {
|
|
331
|
+
expect(
|
|
332
|
+
node.capabilities,
|
|
333
|
+
'Per spec §"Expansion semantics" step 8: chain-level `capabilities[]` MUST be copied to every expanded node so existing capability gates (side-effect, streamable) apply uniformly.',
|
|
334
|
+
).toEqual(['side-effectful', 'cacheable']);
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('omits capabilities[] on expanded nodes when the chain declares none', () => {
|
|
339
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
340
|
+
expansionId: 'c2',
|
|
341
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
342
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
343
|
+
});
|
|
344
|
+
expect(
|
|
345
|
+
at(fragment.nodes, 0, 'fragment.nodes').capabilities,
|
|
346
|
+
'When the chain declares no `capabilities[]`, expanded nodes MUST NOT carry an empty array (preserves wire-shape minimality).',
|
|
347
|
+
).toBeUndefined();
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('category: workflow-chain expansion — runtime-invariance contract', () => {
|
|
352
|
+
it('expanded fragment carries ONLY concrete typeIds (no chain reference survives)', () => {
|
|
353
|
+
const fragment = expandChain(SAMPLE_CHAIN, {
|
|
354
|
+
expansionId: 'r1',
|
|
355
|
+
params: { productIdea: 'p', targetAudience: '' },
|
|
356
|
+
isTypeIdResolvable: RESOLVE_ALL,
|
|
357
|
+
});
|
|
358
|
+
for (const node of fragment.nodes) {
|
|
359
|
+
expect(
|
|
360
|
+
node.typeId,
|
|
361
|
+
'Per workflow-chain-packs.md §"What hosts dispatch": dispatching runtimes see only concrete `core.*`/published-vendor typeIds — the chain reference MUST NOT be preserved at runtime.',
|
|
362
|
+
).not.toMatch(/^vendor\.acme\.generatePRD$/);
|
|
363
|
+
expect(node.typeId).toBe('core.ai.callPrompt');
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow-chain pack expansion — live-host gate (RFC 0013 Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Capability-gated scenario. Skips when the host doesn't advertise
|
|
5
|
+
* `capabilities.workflowChainPacks.supported: true`. Asserts the host's
|
|
6
|
+
* vendor-prefixed expansion endpoint (`POST /v1/host/sample/workflow-
|
|
7
|
+
* chain:expand` — vendor prefix per `host-extensions.md` §"Canonical
|
|
8
|
+
* prefixes") returns expanded fragments equivalent to the spec-
|
|
9
|
+
* authoritative `expandChain()` reference library.
|
|
10
|
+
*
|
|
11
|
+
* Why this exists: the four server-free chain scenarios
|
|
12
|
+
* (manifest-validation, signature-verification, expansion,
|
|
13
|
+
* unresolvable-typeid) cover the pure logic. This scenario proves a
|
|
14
|
+
* reference host wraps the algorithm correctly — fetch + verify +
|
|
15
|
+
* locate + expand — and emits the same wire shape any consumer
|
|
16
|
+
* implementing the spec would. Without it, the RFC's "reference host
|
|
17
|
+
* implements expansion" acceptance criterion cannot be verified
|
|
18
|
+
* end-to-end against an actual deployment.
|
|
19
|
+
*
|
|
20
|
+
* Coverage:
|
|
21
|
+
* 1. Discovery advertises the capability (precondition for the rest).
|
|
22
|
+
* 2. Positive — 1-node chain expands; substituted config + rewritten
|
|
23
|
+
* id + propagated capabilities match the pure-library output for
|
|
24
|
+
* the same input.
|
|
25
|
+
* 3. Positive — 2-node chain with edges expands; edge endpoints
|
|
26
|
+
* reference the rewritten ids.
|
|
27
|
+
* 4. Negative — unknown packName → 404 `pack_not_found`.
|
|
28
|
+
* 5. Negative — known pack, unknown chainId → 404 `chain_not_found`.
|
|
29
|
+
* 6. Negative — malformed body (no chainId) → 422 `invalid_request`.
|
|
30
|
+
*
|
|
31
|
+
* @see spec/v1/workflow-chain-packs.md §"Expansion semantics (normative)"
|
|
32
|
+
* @see capabilities.md §workflowChainPacks
|
|
33
|
+
* @see RFCS/0013-workflow-chain-packs.md (Phase 3)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from 'vitest';
|
|
37
|
+
|
|
38
|
+
import { driver } from '../lib/driver.js';
|
|
39
|
+
import { loadEnv } from '../lib/env.js';
|
|
40
|
+
import { behaviorGate } from '../lib/behavior-gate.js';
|
|
41
|
+
|
|
42
|
+
const PROFILE = 'workflowChainPacks';
|
|
43
|
+
const SAMPLE_PACK = 'vendor.openwop.workflow-chain-sample';
|
|
44
|
+
const CHAIN_1_NODE = 'vendor.openwop.workflow-chain-sample.summarize-text';
|
|
45
|
+
const CHAIN_2_NODE = 'vendor.openwop.workflow-chain-sample.fetch-and-summarize';
|
|
46
|
+
const EXPAND_PATH = '/v1/host/sample/workflow-chain:expand';
|
|
47
|
+
|
|
48
|
+
interface ChainCaps {
|
|
49
|
+
supported?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function isExpansionAdvertised(): Promise<boolean> {
|
|
53
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
54
|
+
const caps =
|
|
55
|
+
(disco.json as { capabilities?: { workflowChainPacks?: ChainCaps } }).capabilities
|
|
56
|
+
?.workflowChainPacks ?? {};
|
|
57
|
+
return caps.supported === true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('workflow-chain-host-expansion: live host wraps expansion algorithm correctly', () => {
|
|
61
|
+
it('host discovery advertises workflowChainPacks.supported when expansion is implemented', async () => {
|
|
62
|
+
loadEnv();
|
|
63
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
64
|
+
|
|
65
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
66
|
+
const caps = (disco.json as { capabilities?: { workflowChainPacks?: ChainCaps } }).capabilities
|
|
67
|
+
?.workflowChainPacks;
|
|
68
|
+
expect(
|
|
69
|
+
caps,
|
|
70
|
+
driver.describe(
|
|
71
|
+
'capabilities.md §workflowChainPacks',
|
|
72
|
+
'host advertising the capability MUST set `supported: true` in the discovery block',
|
|
73
|
+
),
|
|
74
|
+
).toBeDefined();
|
|
75
|
+
expect(caps?.supported).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('positive — 1-node chain expansion via the host returns substituted config + rewritten id', async () => {
|
|
79
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
80
|
+
|
|
81
|
+
const res = await driver.post(EXPAND_PATH, {
|
|
82
|
+
packName: SAMPLE_PACK,
|
|
83
|
+
chainId: CHAIN_1_NODE,
|
|
84
|
+
parameters: {
|
|
85
|
+
sourceText: 'The quick brown fox jumps over the lazy dog.',
|
|
86
|
+
targetLength: 'one-sentence',
|
|
87
|
+
tone: 'casual',
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
const body = res.json as {
|
|
93
|
+
expansionId: string;
|
|
94
|
+
chainId: string;
|
|
95
|
+
packName: string;
|
|
96
|
+
packVersion: string;
|
|
97
|
+
nodes: Array<{
|
|
98
|
+
id: string;
|
|
99
|
+
typeId: string;
|
|
100
|
+
config?: { systemPrompt?: string };
|
|
101
|
+
capabilities?: string[];
|
|
102
|
+
}>;
|
|
103
|
+
edges: Array<unknown>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
expect(body.chainId).toBe(CHAIN_1_NODE);
|
|
107
|
+
expect(body.packName).toBe(SAMPLE_PACK);
|
|
108
|
+
expect(body.packVersion).toBe('1.0.0');
|
|
109
|
+
expect(body.nodes).toHaveLength(1);
|
|
110
|
+
expect(body.edges).toHaveLength(0);
|
|
111
|
+
expect(typeof body.expansionId).toBe('string');
|
|
112
|
+
expect(body.expansionId.length).toBeGreaterThan(0);
|
|
113
|
+
|
|
114
|
+
const node = body.nodes[0]!;
|
|
115
|
+
// Step 6: id rewriting — chainId's dots become underscores +
|
|
116
|
+
// expansionId suffix + original fragment id.
|
|
117
|
+
expect(node.id).toMatch(
|
|
118
|
+
/^vendor_openwop_workflow-chain-sample_summarize-text_[a-f0-9]+_summarize-call$/,
|
|
119
|
+
);
|
|
120
|
+
expect(node.typeId).toBe('core.ai.callPrompt');
|
|
121
|
+
|
|
122
|
+
// Step 5: literal substitution.
|
|
123
|
+
const sysPrompt = node.config?.systemPrompt ?? '';
|
|
124
|
+
expect(sysPrompt).toContain('a one-sentence summary');
|
|
125
|
+
expect(sysPrompt).toContain('a casual tone');
|
|
126
|
+
expect(sysPrompt).toContain('The quick brown fox jumps over the lazy dog.');
|
|
127
|
+
|
|
128
|
+
// Step 8: capability propagation.
|
|
129
|
+
expect(node.capabilities).toEqual(['cacheable']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('positive — 2-node chain with edges expands with rewritten edge endpoints', async () => {
|
|
133
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
134
|
+
|
|
135
|
+
const res = await driver.post(EXPAND_PATH, {
|
|
136
|
+
packName: SAMPLE_PACK,
|
|
137
|
+
chainId: CHAIN_2_NODE,
|
|
138
|
+
parameters: {
|
|
139
|
+
url: 'https://example.com/article',
|
|
140
|
+
targetLength: 'executive-summary',
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
expect(res.status).toBe(200);
|
|
144
|
+
|
|
145
|
+
const body = res.json as {
|
|
146
|
+
expansionId: string;
|
|
147
|
+
nodes: Array<{ id: string; typeId: string; capabilities?: string[] }>;
|
|
148
|
+
edges: Array<{ from: string; to: string }>;
|
|
149
|
+
};
|
|
150
|
+
expect(body.nodes).toHaveLength(2);
|
|
151
|
+
expect(body.edges).toHaveLength(1);
|
|
152
|
+
|
|
153
|
+
// Both expanded nodes get the same prefix; the edge's `from`/`to`
|
|
154
|
+
// refer to fragment node ids and so get rewritten with the same
|
|
155
|
+
// prefix (port suffix preserved).
|
|
156
|
+
const edge = body.edges[0]!;
|
|
157
|
+
const prefix = `vendor_openwop_workflow-chain-sample_fetch-and-summarize_${body.expansionId}_`;
|
|
158
|
+
expect(edge.from).toBe(`${prefix}fetch.body`);
|
|
159
|
+
expect(edge.to).toBe(`${prefix}summarize.sourceText`);
|
|
160
|
+
|
|
161
|
+
// side-effectful capability propagated to BOTH expanded nodes.
|
|
162
|
+
for (const node of body.nodes) {
|
|
163
|
+
expect(node.capabilities, `node ${node.id} inherits chain capability`).toEqual(['side-effectful']);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('negative — unknown pack returns 404 pack_not_found', async () => {
|
|
168
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
169
|
+
|
|
170
|
+
const res = await driver.post(EXPAND_PATH, {
|
|
171
|
+
packName: 'vendor.acme.does-not-exist',
|
|
172
|
+
chainId: 'whatever',
|
|
173
|
+
parameters: {},
|
|
174
|
+
});
|
|
175
|
+
expect(res.status).toBe(404);
|
|
176
|
+
expect((res.json as { error: string }).error).toBe('pack_not_found');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('negative — known pack but unknown chainId returns 404 chain_not_found', async () => {
|
|
180
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
181
|
+
|
|
182
|
+
const res = await driver.post(EXPAND_PATH, {
|
|
183
|
+
packName: SAMPLE_PACK,
|
|
184
|
+
chainId: 'vendor.openwop.workflow-chain-sample.does-not-exist',
|
|
185
|
+
parameters: {},
|
|
186
|
+
});
|
|
187
|
+
expect(res.status).toBe(404);
|
|
188
|
+
expect((res.json as { error: string }).error).toBe('chain_not_found');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('negative — malformed request body returns 422 invalid_request', async () => {
|
|
192
|
+
if (!behaviorGate(PROFILE, await isExpansionAdvertised())) return;
|
|
193
|
+
|
|
194
|
+
// Missing chainId.
|
|
195
|
+
const res = await driver.post(EXPAND_PATH, {
|
|
196
|
+
packName: SAMPLE_PACK,
|
|
197
|
+
parameters: {},
|
|
198
|
+
});
|
|
199
|
+
expect(res.status).toBe(422);
|
|
200
|
+
expect((res.json as { error: string }).error).toBe('invalid_request');
|
|
201
|
+
});
|
|
202
|
+
});
|