@openwop/openwop-conformance 1.0.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/LICENSE +201 -0
- package/README.md +241 -0
- package/api/asyncapi.yaml +481 -0
- package/api/openapi.yaml +830 -0
- package/api/redocly.yaml +8 -0
- package/coverage.md +80 -0
- package/dist/cli.js +161 -0
- package/fixtures/conformance-a2a-task-roundtrip.json +27 -0
- package/fixtures/conformance-agent-identity.json +27 -0
- package/fixtures/conformance-agent-low-confidence.json +29 -0
- package/fixtures/conformance-agent-memory-cross-tenant.json +28 -0
- package/fixtures/conformance-agent-memory-redaction.json +32 -0
- package/fixtures/conformance-agent-memory-roundtrip.json +32 -0
- package/fixtures/conformance-agent-memory-ttl.json +31 -0
- package/fixtures/conformance-agent-pack-export.json +26 -0
- package/fixtures/conformance-agent-pack-install.json +26 -0
- package/fixtures/conformance-agent-pack-provenance.json +31 -0
- package/fixtures/conformance-agent-reasoning.json +29 -0
- package/fixtures/conformance-approval.json +27 -0
- package/fixtures/conformance-cancellable.json +33 -0
- package/fixtures/conformance-cap-breach.json +27 -0
- package/fixtures/conformance-capability-missing.json +23 -0
- package/fixtures/conformance-channel-ttl.json +60 -0
- package/fixtures/conformance-clarification.json +30 -0
- package/fixtures/conformance-conversation-capability-negotiation.json +23 -0
- package/fixtures/conformance-conversation-lifecycle.json +32 -0
- package/fixtures/conformance-conversation-replay.json +33 -0
- package/fixtures/conformance-conversation-vs-clarification.json +26 -0
- package/fixtures/conformance-delay.json +33 -0
- package/fixtures/conformance-dispatch-loop.json +38 -0
- package/fixtures/conformance-failure.json +23 -0
- package/fixtures/conformance-idempotent.json +30 -0
- package/fixtures/conformance-identity.json +32 -0
- package/fixtures/conformance-interrupt-auth-required.json +28 -0
- package/fixtures/conformance-interrupt-external-event.json +33 -0
- package/fixtures/conformance-interrupt-parent-child-cancel-child.json +27 -0
- package/fixtures/conformance-interrupt-parent-child-cancel.json +26 -0
- package/fixtures/conformance-interrupt-quorum.json +30 -0
- package/fixtures/conformance-mcp-tool-roundtrip.json +32 -0
- package/fixtures/conformance-message-reducer.json +31 -0
- package/fixtures/conformance-multi-node.json +21 -0
- package/fixtures/conformance-noop.json +23 -0
- package/fixtures/conformance-orchestrator-dispatch.json +47 -0
- package/fixtures/conformance-orchestrator-low-confidence.json +41 -0
- package/fixtures/conformance-orchestrator-terminate.json +44 -0
- package/fixtures/conformance-stream-text.json +26 -0
- package/fixtures/conformance-subworkflow-child.json +21 -0
- package/fixtures/conformance-subworkflow-parent.json +49 -0
- package/fixtures/conformance-version-fold.json +23 -0
- package/fixtures/conformance-wasm-pack-roundtrip.json +25 -0
- package/fixtures/pack-manifests/pack-private-example.json +26 -0
- package/fixtures.md +404 -0
- package/package.json +48 -0
- package/schemas/README.md +75 -0
- package/schemas/agent-manifest.schema.json +107 -0
- package/schemas/agent-ref.schema.json +53 -0
- package/schemas/capabilities.schema.json +287 -0
- package/schemas/channel-written-payload.schema.json +55 -0
- package/schemas/conversation-event.schema.json +120 -0
- package/schemas/conversation-turn.schema.json +72 -0
- package/schemas/debug-bundle.schema.json +196 -0
- package/schemas/dispatch-config.schema.json +46 -0
- package/schemas/error-envelope.schema.json +25 -0
- package/schemas/memory-entry.schema.json +36 -0
- package/schemas/memory-list-options.schema.json +21 -0
- package/schemas/node-pack-manifest.schema.json +235 -0
- package/schemas/orchestrator-decision.schema.json +60 -0
- package/schemas/run-event-payloads.schema.json +663 -0
- package/schemas/run-event.schema.json +116 -0
- package/schemas/run-options.schema.json +81 -0
- package/schemas/run-orchestrator-decided-event.schema.json +20 -0
- package/schemas/run-snapshot.schema.json +121 -0
- package/schemas/suspend-request.schema.json +182 -0
- package/schemas/workflow-definition.schema.json +430 -0
- package/src/cli.ts +187 -0
- package/src/lib/a2a-fake-peer.ts +233 -0
- package/src/lib/canaries.ts +186 -0
- package/src/lib/driver.ts +96 -0
- package/src/lib/env.ts +49 -0
- package/src/lib/fixtures.ts +93 -0
- package/src/lib/mcp-fake-server.ts +185 -0
- package/src/lib/multi-agent-capabilities.ts +155 -0
- package/src/lib/multiProcess.ts +141 -0
- package/src/lib/otel-collector.ts +312 -0
- package/src/lib/paths.ts +198 -0
- package/src/lib/polling.ts +81 -0
- package/src/lib/profiles.ts +258 -0
- package/src/lib/sse.ts +172 -0
- package/src/scenarios/a2a-task-roundtrip.test.ts +149 -0
- package/src/scenarios/agentConfidenceEscalation.test.ts +61 -0
- package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +54 -0
- package/src/scenarios/agentMemoryRedactionContract.test.ts +46 -0
- package/src/scenarios/agentMemoryRoundTrip.test.ts +52 -0
- package/src/scenarios/agentMemoryTtlExpiry.test.ts +47 -0
- package/src/scenarios/agentMessageReducer.test.ts +57 -0
- package/src/scenarios/agentMetadata.test.ts +56 -0
- package/src/scenarios/agentPackExport.test.ts +45 -0
- package/src/scenarios/agentPackInstall.test.ts +50 -0
- package/src/scenarios/agentPackProvenance.test.ts +53 -0
- package/src/scenarios/agentReasoningEvents.test.ts +72 -0
- package/src/scenarios/append-ordering.test.ts +91 -0
- package/src/scenarios/approval-payload.test.ts +120 -0
- package/src/scenarios/audit-log-integrity.test.ts +106 -0
- package/src/scenarios/auth.test.ts +55 -0
- package/src/scenarios/byok-roundtrip.test.ts +166 -0
- package/src/scenarios/cancellation.test.ts +68 -0
- package/src/scenarios/cap-breach.test.ts +149 -0
- package/src/scenarios/channel-ttl.test.ts +70 -0
- package/src/scenarios/configurable-schema.test.ts +76 -0
- package/src/scenarios/conversationCapabilityNegotiation.test.ts +39 -0
- package/src/scenarios/conversationLifecycle.test.ts +64 -0
- package/src/scenarios/conversationReplayDeterminism.test.ts +52 -0
- package/src/scenarios/conversationVsLegacySuspend.test.ts +46 -0
- package/src/scenarios/cost-attribution.test.ts +207 -0
- package/src/scenarios/debugBundle.test.ts +222 -0
- package/src/scenarios/discovery.test.ts +147 -0
- package/src/scenarios/dispatchLoop.test.ts +52 -0
- package/src/scenarios/errors.test.ts +144 -0
- package/src/scenarios/eventOrdering.test.ts +144 -0
- package/src/scenarios/failure-path.test.ts +46 -0
- package/src/scenarios/fixtures-gating.test.ts +137 -0
- package/src/scenarios/fixtures-valid.test.ts +140 -0
- package/src/scenarios/highConcurrency.test.ts +263 -0
- package/src/scenarios/idempotency.test.ts +83 -0
- package/src/scenarios/idempotencyRetry.test.ts +130 -0
- package/src/scenarios/identity-passthrough.test.ts +54 -0
- package/src/scenarios/interrupt-approval.test.ts +97 -0
- package/src/scenarios/interrupt-auth-required-resume.test.ts +88 -0
- package/src/scenarios/interrupt-clarification.test.ts +45 -0
- package/src/scenarios/interrupt-external-event-correlation.test.ts +113 -0
- package/src/scenarios/interrupt-parent-child-cascade.test.ts +102 -0
- package/src/scenarios/interrupt-quorum-resolution.test.ts +97 -0
- package/src/scenarios/interruptRace.test.ts +176 -0
- package/src/scenarios/maliciousManifest.test.ts +154 -0
- package/src/scenarios/mcp-discoverability.test.ts +129 -0
- package/src/scenarios/mcp-tool-roundtrip.test.ts +149 -0
- package/src/scenarios/multi-node-ordering.test.ts +60 -0
- package/src/scenarios/multi-region-idempotency.test.ts +52 -0
- package/src/scenarios/orchestratorConservativePath.test.ts +63 -0
- package/src/scenarios/orchestratorDispatch.test.ts +66 -0
- package/src/scenarios/orchestratorTermination.test.ts +54 -0
- package/src/scenarios/otel-emission.test.ts +113 -0
- package/src/scenarios/otel-trace-propagation.test.ts +90 -0
- package/src/scenarios/pack-registry-publish.test.ts +93 -0
- package/src/scenarios/pack-registry.test.ts +328 -0
- package/src/scenarios/pause-resume.test.ts +109 -0
- package/src/scenarios/policies.test.ts +162 -0
- package/src/scenarios/profileDerivation.test.ts +335 -0
- package/src/scenarios/providerPolicyEnforcement.test.ts +132 -0
- package/src/scenarios/rate-limit-envelope.test.ts +97 -0
- package/src/scenarios/redaction.test.ts +254 -0
- package/src/scenarios/redactionAdversarial.test.ts +162 -0
- package/src/scenarios/replay-fork-arbitrary.test.ts +347 -0
- package/src/scenarios/replay-fork.test.ts +216 -0
- package/src/scenarios/replayDeterminism.test.ts +171 -0
- package/src/scenarios/route-coverage.test.ts +129 -0
- package/src/scenarios/runs-lifecycle.test.ts +65 -0
- package/src/scenarios/runtime-capabilities.test.ts +118 -0
- package/src/scenarios/spec-corpus-validity.test.ts +1257 -0
- package/src/scenarios/staleClaim.test.ts +223 -0
- package/src/scenarios/stream-modes-buffer.test.ts +148 -0
- package/src/scenarios/stream-modes-mixed.test.ts +149 -0
- package/src/scenarios/stream-modes.test.ts +139 -0
- package/src/scenarios/streamReconnect.test.ts +162 -0
- package/src/scenarios/subworkflow.test.ts +126 -0
- package/src/scenarios/version-negotiation.test.ts +157 -0
- package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +47 -0
- package/src/scenarios/wasm-pack-invoke-completed.test.ts +69 -0
- package/src/scenarios/wasm-pack-invoke-suspended.test.ts +74 -0
- package/src/scenarios/wasm-pack-load.test.ts +75 -0
- package/src/scenarios/wasm-pack-memory-cap.test.ts +43 -0
- package/src/scenarios/wasm-pack-replay-determinism.test.ts +61 -0
- package/src/scenarios/webhook-sig-algorithm.test.ts +61 -0
- package/src/setup.ts +173 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Malicious-manifest scenarios — verify the node-pack registry rejects
|
|
3
|
+
* adversarial submission shapes per `spec/v1/registry-operations.md`
|
|
4
|
+
* §"Submission validation."
|
|
5
|
+
*
|
|
6
|
+
* Profile gating: the host's `openwop-node-packs` profile is satisfied at
|
|
7
|
+
* runtime via the registry HTTP API. Hosts that don't expose the
|
|
8
|
+
* registry routes (404 on every endpoint) skip-equivalent here.
|
|
9
|
+
*
|
|
10
|
+
* Surfaces covered:
|
|
11
|
+
*
|
|
12
|
+
* 1. **manifest_name_mismatch** — manifest's `name` field differs
|
|
13
|
+
* from the URL path's name segment.
|
|
14
|
+
* 2. **manifest_version_mismatch** — manifest's `version` field
|
|
15
|
+
* differs from the URL path's version segment.
|
|
16
|
+
* 3. **invalid_pack_name** — URL path's name segment fails the
|
|
17
|
+
* registry's name regex.
|
|
18
|
+
* 4. **invalid_version** — URL path's version segment fails semver.
|
|
19
|
+
* 5. **tarball_path_traversal** — registry rejects tarballs whose
|
|
20
|
+
* entries include `..` or absolute paths (this scenario can only
|
|
21
|
+
* assert the rejection-shape contract; constructing a real
|
|
22
|
+
* malicious tarball requires registry-internal helpers).
|
|
23
|
+
* 6. **idempotent re-publish** — sha256-identical content for an
|
|
24
|
+
* existing (name, version) returns 200 with the existing record,
|
|
25
|
+
* NOT 409.
|
|
26
|
+
*
|
|
27
|
+
* Cross-references SECURITY/threat-model-node-packs.md invariants
|
|
28
|
+
* `node-pack-manifest-name-match` · `node-pack-manifest-version-match` ·
|
|
29
|
+
* `node-pack-path-traversal` · `node-pack-scope-author-match`.
|
|
30
|
+
*
|
|
31
|
+
* @see spec/v1/node-packs.md §Registry HTTP API
|
|
32
|
+
* @see spec/v1/registry-operations.md §Submission validation
|
|
33
|
+
* @see SECURITY/threat-model-node-packs.md
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { describe, it, expect } from 'vitest';
|
|
37
|
+
import { driver } from '../lib/driver.js';
|
|
38
|
+
|
|
39
|
+
interface RegistryProbe {
|
|
40
|
+
available: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function probeRegistry(): Promise<RegistryProbe> {
|
|
44
|
+
// Cheapest probe: GET on a guaranteed-nonexistent pack should return
|
|
45
|
+
// either a structured 404 (registry available, no such pack) OR
|
|
46
|
+
// simply 404 with no JSON body (host doesn't have a registry — every
|
|
47
|
+
// /v1/packs/* route is a generic 404).
|
|
48
|
+
const res = await driver.get('/v1/packs/probe-no-such-pack/-/0.0.0.json');
|
|
49
|
+
if (res.status === 404 && typeof res.json === 'object' && res.json !== null) {
|
|
50
|
+
const body = res.json as { error?: unknown };
|
|
51
|
+
if (typeof body.error === 'string') return { available: true };
|
|
52
|
+
}
|
|
53
|
+
// 404 without structured body, or any non-404, suggests no real registry.
|
|
54
|
+
return { available: false };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('malicious-manifest: pack-name validation per spec/v1/node-packs.md §Naming', () => {
|
|
58
|
+
it('GET /v1/packs/{bad-name}/-/{version}.json returns 400 invalid_pack_name', async () => {
|
|
59
|
+
const probe = await probeRegistry();
|
|
60
|
+
if (!probe.available) return; // host doesn't claim openwop-node-packs
|
|
61
|
+
|
|
62
|
+
// Bad name shapes the registry SHOULD reject:
|
|
63
|
+
// - Reserved scope without authorization (`core.foo`)
|
|
64
|
+
// - Invalid characters (`Bad Name`)
|
|
65
|
+
// - Empty / too short
|
|
66
|
+
const badNames = ['Bad Name', 'name with spaces', 'a'];
|
|
67
|
+
|
|
68
|
+
for (const badName of badNames) {
|
|
69
|
+
const res = await driver.get(
|
|
70
|
+
`/v1/packs/${encodeURIComponent(badName)}/-/1.0.json`,
|
|
71
|
+
);
|
|
72
|
+
expect(
|
|
73
|
+
[400, 404].includes(res.status),
|
|
74
|
+
driver.describe(
|
|
75
|
+
'spec/v1/node-packs.md §Registry HTTP API',
|
|
76
|
+
`bad pack name "${badName}" MUST yield 400 (invalid_pack_name) or 404 (treated as unknown)`,
|
|
77
|
+
),
|
|
78
|
+
).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe('malicious-manifest: version validation', () => {
|
|
84
|
+
it('GET /v1/packs/{name}/-/{bad-version}.json returns 400 invalid_version', async () => {
|
|
85
|
+
const probe = await probeRegistry();
|
|
86
|
+
if (!probe.available) return;
|
|
87
|
+
|
|
88
|
+
const badVersions = ['not-semver', '1', '1.0.0', 'v1.0'];
|
|
89
|
+
|
|
90
|
+
for (const bad of badVersions) {
|
|
91
|
+
const res = await driver.get(
|
|
92
|
+
`/v1/packs/community.test/-/${encodeURIComponent(bad)}.json`,
|
|
93
|
+
);
|
|
94
|
+
expect(
|
|
95
|
+
[400, 404].includes(res.status),
|
|
96
|
+
driver.describe(
|
|
97
|
+
'spec/v1/node-packs.md §Registry HTTP API',
|
|
98
|
+
`bad version "${bad}" MUST yield 400 (invalid_version) or 404`,
|
|
99
|
+
),
|
|
100
|
+
).toBe(true);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('malicious-manifest: signature endpoint contract per openwop/openwop@434c8f2', () => {
|
|
106
|
+
it('GET /v1/packs/{name}/-/{version}.sig of a non-existent pack returns 404 signature_not_available', async () => {
|
|
107
|
+
const probe = await probeRegistry();
|
|
108
|
+
if (!probe.available) return;
|
|
109
|
+
|
|
110
|
+
const res = await driver.get('/v1/packs/community.no-such-pack/-/1.0.sig');
|
|
111
|
+
expect(res.status, driver.describe(
|
|
112
|
+
'spec/v1/node-packs.md §`GET .sig`',
|
|
113
|
+
'missing/yanked/unsigned signature MUST return 404',
|
|
114
|
+
)).toBe(404);
|
|
115
|
+
|
|
116
|
+
if (typeof res.json === 'object' && res.json !== null) {
|
|
117
|
+
const body = res.json as { error?: unknown };
|
|
118
|
+
// Per openwop/openwop@434c8f2 the unified error code is
|
|
119
|
+
// `signature_not_available`. Hosts MAY use a more general 404
|
|
120
|
+
// shape; the assertion is permissive on the error code itself
|
|
121
|
+
// but strict on the status.
|
|
122
|
+
if (typeof body.error === 'string') {
|
|
123
|
+
expect(body.error.length, driver.describe(
|
|
124
|
+
'spec/v1/node-packs.md',
|
|
125
|
+
'404 response MUST carry a structured error envelope with a non-empty error code',
|
|
126
|
+
)).toBeGreaterThan(0);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('malicious-manifest: documented error catalog (per openwop/openwop@434c8f2)', () => {
|
|
133
|
+
it('lists are non-empty (sanity check on doc drift)', () => {
|
|
134
|
+
// Self-test: if the documented PUT-publish error catalog drifts
|
|
135
|
+
// and the scenario file isn't updated, this assertion catches the
|
|
136
|
+
// truncation. Each name corresponds to a normative error code from
|
|
137
|
+
// node-packs.md §Registry HTTP API.
|
|
138
|
+
const TARBALL_ERRORS = [
|
|
139
|
+
'tarball_gunzip_failed',
|
|
140
|
+
'tarball_too_large',
|
|
141
|
+
'tarball_manifest_missing',
|
|
142
|
+
'tarball_manifest_too_large',
|
|
143
|
+
'tarball_manifest_not_json',
|
|
144
|
+
'tarball_entry_missing',
|
|
145
|
+
'tarball_entry_too_large',
|
|
146
|
+
'tarball_path_traversal',
|
|
147
|
+
'tarball_tar_parse_failed',
|
|
148
|
+
] as const;
|
|
149
|
+
expect(TARBALL_ERRORS.length, driver.describe(
|
|
150
|
+
'spec/v1/node-packs.md',
|
|
151
|
+
'documented tarball-error catalog is non-empty',
|
|
152
|
+
)).toBe(9);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP-discoverability scenarios.
|
|
3
|
+
*
|
|
4
|
+
* `spec/v1/mcp-integration.md` §"Conformance + interop" calls out the
|
|
5
|
+
* MCP slot as host-implementation-defined (not a normative openwop field).
|
|
6
|
+
* The spec doesn't prescribe a wire-level MCP integration, but it
|
|
7
|
+
* DOES say an OpenWOP host that supports MCP "advertises the capability
|
|
8
|
+
* and (per the host's choice) lists supported MCP servers."
|
|
9
|
+
*
|
|
10
|
+
* Convention (matches lib/profiles.ts + reference hosts): the
|
|
11
|
+
* `/.well-known/openwop` body itself IS the capabilities object — there
|
|
12
|
+
* is no `capabilities` envelope. `replay`, `secrets`, `extensions`,
|
|
13
|
+
* etc. all live at the top level.
|
|
14
|
+
*
|
|
15
|
+
* What this scenario locks in: IF a host advertises MCP-compatibility
|
|
16
|
+
* — under either the standard top-level `mcp` slot OR a vendor-
|
|
17
|
+
* namespaced slot like `<vendor>.mcp` — it MUST follow a consistent
|
|
18
|
+
* shape so clients can discover serverUrls without per-vendor coupling.
|
|
19
|
+
*
|
|
20
|
+
* Required shape (when advertised):
|
|
21
|
+
* { supported: boolean, serverUrls: string[] }
|
|
22
|
+
*
|
|
23
|
+
* Hosts that don't advertise any MCP capability skip-equivalent
|
|
24
|
+
* (test passes with no failed assertions per suite convention).
|
|
25
|
+
*
|
|
26
|
+
* @see spec/v1/mcp-integration.md
|
|
27
|
+
* @see spec/v1/positioning.md (why MCP composes with openwop)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect } from 'vitest';
|
|
31
|
+
import { driver } from '../lib/driver.js';
|
|
32
|
+
|
|
33
|
+
interface McpAdvertisement {
|
|
34
|
+
supported?: unknown;
|
|
35
|
+
serverUrls?: unknown;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface DiscoveredMcp {
|
|
39
|
+
path: string;
|
|
40
|
+
ad: McpAdvertisement;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function collectMcpAdvertisements(discovery: unknown): DiscoveredMcp[] {
|
|
44
|
+
if (discovery === null || typeof discovery !== 'object') return [];
|
|
45
|
+
const out: DiscoveredMcp[] = [];
|
|
46
|
+
const obj = discovery as Record<string, unknown>;
|
|
47
|
+
|
|
48
|
+
// Standard slot — top level of the discovery body per
|
|
49
|
+
// mcp-integration.md §"Conformance + interop"
|
|
50
|
+
if (obj.mcp !== null && typeof obj.mcp === 'object') {
|
|
51
|
+
out.push({ path: 'mcp', ad: obj.mcp as McpAdvertisement });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Vendor-namespaced slot (host-implementation-defined per spec).
|
|
55
|
+
// Scans every top-level object value for a nested `mcp` field;
|
|
56
|
+
// false-positive risk is low because non-namespace top-level fields
|
|
57
|
+
// (limits, schemaVersions, etc.) don't carry an `mcp` key.
|
|
58
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
59
|
+
if (key === 'mcp') continue;
|
|
60
|
+
if (value === null || typeof value !== 'object') continue;
|
|
61
|
+
const inner = value as Record<string, unknown>;
|
|
62
|
+
if ('mcp' in inner && inner.mcp !== null && typeof inner.mcp === 'object') {
|
|
63
|
+
out.push({ path: `${key}.mcp`, ad: inner.mcp as McpAdvertisement });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchMcpAdvertisements(): Promise<DiscoveredMcp[]> {
|
|
70
|
+
const res = await driver.get('/.well-known/openwop', { authenticated: false });
|
|
71
|
+
if (res.status !== 200) return [];
|
|
72
|
+
return collectMcpAdvertisements(res.json);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('mcp: discoverability shape', () => {
|
|
76
|
+
it('any advertised MCP capability has well-formed shape ({supported, serverUrls})', async () => {
|
|
77
|
+
const advertisements = await fetchMcpAdvertisements();
|
|
78
|
+
if (advertisements.length === 0) return; // skip-equivalent: host does not advertise MCP
|
|
79
|
+
|
|
80
|
+
for (const { path, ad } of advertisements) {
|
|
81
|
+
expect(typeof ad.supported, driver.describe(
|
|
82
|
+
'spec/v1/mcp-integration.md §"Conformance + interop"',
|
|
83
|
+
`${path}.supported MUST be boolean when advertised`,
|
|
84
|
+
)).toBe('boolean');
|
|
85
|
+
|
|
86
|
+
if (ad.supported === true) {
|
|
87
|
+
expect(Array.isArray(ad.serverUrls), driver.describe(
|
|
88
|
+
'spec/v1/mcp-integration.md',
|
|
89
|
+
`${path}.serverUrls MUST be an array when supported:true`,
|
|
90
|
+
)).toBe(true);
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(ad.serverUrls)) {
|
|
93
|
+
expect(ad.serverUrls.length, driver.describe(
|
|
94
|
+
'spec/v1/mcp-integration.md',
|
|
95
|
+
`${path}.serverUrls MUST be non-empty when supported:true`,
|
|
96
|
+
)).toBeGreaterThan(0);
|
|
97
|
+
|
|
98
|
+
for (const url of ad.serverUrls) {
|
|
99
|
+
expect(typeof url, driver.describe(
|
|
100
|
+
'spec/v1/mcp-integration.md',
|
|
101
|
+
`${path}.serverUrls entries MUST be strings`,
|
|
102
|
+
)).toBe('string');
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('serverUrls are valid URL paths or absolute URLs', async () => {
|
|
110
|
+
const advertisements = await fetchMcpAdvertisements();
|
|
111
|
+
if (advertisements.length === 0) return; // skip-equivalent
|
|
112
|
+
|
|
113
|
+
for (const { path, ad } of advertisements) {
|
|
114
|
+
if (ad.supported !== true || !Array.isArray(ad.serverUrls)) continue;
|
|
115
|
+
for (const url of ad.serverUrls) {
|
|
116
|
+
if (typeof url !== 'string') continue;
|
|
117
|
+
// Must be either a leading-slash path (host-relative) or an
|
|
118
|
+
// absolute URL with http/https scheme. Anything else is
|
|
119
|
+
// ambiguous to a client trying to connect.
|
|
120
|
+
const isHostRelative = url.startsWith('/');
|
|
121
|
+
const isAbsoluteHttp = url.startsWith('http://') || url.startsWith('https://');
|
|
122
|
+
expect(isHostRelative || isAbsoluteHttp, driver.describe(
|
|
123
|
+
'spec/v1/mcp-integration.md',
|
|
124
|
+
`${path}.serverUrls entry "${url}" MUST be a leading-slash path or absolute http(s) URL`,
|
|
125
|
+
)).toBe(true);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track 6: MCP tool-call roundtrip conformance.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the host's MCP integration honors the documented trust
|
|
5
|
+
* boundary from `spec/v1/mcp-integration.md` and
|
|
6
|
+
* `SECURITY/threat-model-prompt-injection.md`:
|
|
7
|
+
*
|
|
8
|
+
* 1. The host can connect to an MCP server, list its tools, and call
|
|
9
|
+
* `tools/call` (basic protocol fidelity).
|
|
10
|
+
* 2. Tool responses surface in the run's event log with the trust
|
|
11
|
+
* boundary intact — payloads are clearly attributable to the MCP
|
|
12
|
+
* server, never silently merged into trusted state.
|
|
13
|
+
*
|
|
14
|
+
* Two-level scenario:
|
|
15
|
+
*
|
|
16
|
+
* - **Direct fake-server probe** (always runs when collector started):
|
|
17
|
+
* hits the in-process fake MCP server directly with initialize +
|
|
18
|
+
* tools/list + tools/call to verify its wire shape. Catches
|
|
19
|
+
* regressions in our own test fixture.
|
|
20
|
+
*
|
|
21
|
+
* - **Host-mediated roundtrip** (runs when host advertises an MCP
|
|
22
|
+
* fixture or roundtrip capability): starts a workflow run, observes
|
|
23
|
+
* events, asserts tool-call envelope visibility. Skips otherwise.
|
|
24
|
+
*
|
|
25
|
+
* Operator contract:
|
|
26
|
+
* `OPENWOP_MCP_FAKE_SERVER=true` on the suite side; configure the host
|
|
27
|
+
* to use the printed fake-server URL as one of its MCP servers.
|
|
28
|
+
*
|
|
29
|
+
* @see spec/v1/mcp-integration.md
|
|
30
|
+
* @see SECURITY/threat-model-prompt-injection.md
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest';
|
|
34
|
+
import { driver } from '../lib/driver.js';
|
|
35
|
+
import { getMcpFakeServer } from '../lib/mcp-fake-server.js';
|
|
36
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
37
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
38
|
+
|
|
39
|
+
const ROUNDTRIP_FIXTURE = 'conformance-mcp-tool-roundtrip';
|
|
40
|
+
|
|
41
|
+
async function postJsonRpc(
|
|
42
|
+
endpoint: string,
|
|
43
|
+
method: string,
|
|
44
|
+
params: unknown,
|
|
45
|
+
id: number,
|
|
46
|
+
): Promise<{ status: number; json: Record<string, unknown> }> {
|
|
47
|
+
const res = await fetch(`${endpoint}/`, {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: { 'Content-Type': 'application/json' },
|
|
50
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
51
|
+
});
|
|
52
|
+
const text = await res.text();
|
|
53
|
+
return { status: res.status, json: JSON.parse(text) as Record<string, unknown> };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe('mcp-tool-roundtrip: fake-server wire shape', () => {
|
|
57
|
+
it('initialize + tools/list + tools/call echo round-trip cleanly', async () => {
|
|
58
|
+
const server = getMcpFakeServer();
|
|
59
|
+
if (!server) {
|
|
60
|
+
// eslint-disable-next-line no-console
|
|
61
|
+
console.warn(
|
|
62
|
+
'[mcp-tool-roundtrip] fake server not started; set OPENWOP_MCP_FAKE_SERVER=true',
|
|
63
|
+
);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
server.reset();
|
|
67
|
+
|
|
68
|
+
const init = await postJsonRpc(server.endpoint(), 'initialize', {}, 1);
|
|
69
|
+
expect(init.status).toBe(200);
|
|
70
|
+
const initResult = (init.json.result ?? {}) as { protocolVersion?: string };
|
|
71
|
+
expect(typeof initResult.protocolVersion).toBe('string');
|
|
72
|
+
|
|
73
|
+
const list = await postJsonRpc(server.endpoint(), 'tools/list', {}, 2);
|
|
74
|
+
expect(list.status).toBe(200);
|
|
75
|
+
const listResult = (list.json.result ?? {}) as {
|
|
76
|
+
tools?: ReadonlyArray<{ name?: string }>;
|
|
77
|
+
};
|
|
78
|
+
expect(listResult.tools?.some((t) => t.name === 'echo')).toBe(true);
|
|
79
|
+
|
|
80
|
+
const call = await postJsonRpc(
|
|
81
|
+
server.endpoint(),
|
|
82
|
+
'tools/call',
|
|
83
|
+
{ name: 'echo', arguments: { text: 'hello-from-conformance' } },
|
|
84
|
+
3,
|
|
85
|
+
);
|
|
86
|
+
expect(call.status).toBe(200);
|
|
87
|
+
const callResult = (call.json.result ?? {}) as {
|
|
88
|
+
content?: ReadonlyArray<{ type?: string; text?: string }>;
|
|
89
|
+
};
|
|
90
|
+
expect(callResult.content?.[0]?.type).toBe('text');
|
|
91
|
+
expect(callResult.content?.[0]?.text).toBe('hello-from-conformance');
|
|
92
|
+
|
|
93
|
+
// Invocation log captured.
|
|
94
|
+
const invocations = server.invocations();
|
|
95
|
+
const methods = invocations.map((i) => i.method);
|
|
96
|
+
expect(methods).toEqual(['initialize', 'tools/list', 'tools/call']);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('mcp-tool-roundtrip: host-mediated tool invocation', () => {
|
|
101
|
+
it('host invokes the configured MCP server and surfaces the tool response in the event log', async () => {
|
|
102
|
+
const server = getMcpFakeServer();
|
|
103
|
+
if (!server) {
|
|
104
|
+
// eslint-disable-next-line no-console
|
|
105
|
+
console.warn('[mcp-tool-roundtrip] fake server not started; skipping host-mediated test');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!isFixtureAdvertised(ROUNDTRIP_FIXTURE)) {
|
|
109
|
+
// eslint-disable-next-line no-console
|
|
110
|
+
console.warn(
|
|
111
|
+
`[mcp-tool-roundtrip] fixture ${ROUNDTRIP_FIXTURE} not advertised; skipping`,
|
|
112
|
+
);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
server.reset();
|
|
117
|
+
|
|
118
|
+
const create = await driver.post('/v1/runs', {
|
|
119
|
+
workflowId: ROUNDTRIP_FIXTURE,
|
|
120
|
+
inputs: { text: 'roundtrip-probe' },
|
|
121
|
+
});
|
|
122
|
+
expect(create.status).toBe(201);
|
|
123
|
+
const runId = (create.json as { runId: string }).runId;
|
|
124
|
+
|
|
125
|
+
await pollUntilTerminal(runId, { timeoutMs: 30_000 });
|
|
126
|
+
|
|
127
|
+
const invocations = server.invocations();
|
|
128
|
+
const toolCalls = invocations.filter((i) => i.method === 'tools/call');
|
|
129
|
+
expect(toolCalls.length, driver.describe(
|
|
130
|
+
'mcp-integration.md §"Tool invocation"',
|
|
131
|
+
'host MUST invoke `tools/call` on the configured MCP server during the fixture run',
|
|
132
|
+
)).toBeGreaterThan(0);
|
|
133
|
+
|
|
134
|
+
// Trust-boundary assertion: the tool-call envelope MUST appear in the
|
|
135
|
+
// run's event log so observers can attribute its content to the
|
|
136
|
+
// MCP server (not to trusted user input). See threat-model-prompt-injection.md
|
|
137
|
+
// §"UNTRUSTED marker" — hosts MAY surface this via a dedicated event
|
|
138
|
+
// type (e.g., `agent.toolReturned`, `mcp.tool.called`) or a marked
|
|
139
|
+
// field on a node-completed payload. This scenario asserts SOME event
|
|
140
|
+
// mentions the tool name to confirm visibility.
|
|
141
|
+
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
142
|
+
const list = (events.json as { events?: Array<{ type: string; payload?: unknown }> }).events ?? [];
|
|
143
|
+
const haystack = JSON.stringify(list).toLowerCase();
|
|
144
|
+
expect(haystack.includes('echo'), driver.describe(
|
|
145
|
+
'mcp-integration.md + threat-model-prompt-injection.md §"UNTRUSTED marker"',
|
|
146
|
+
'host event log MUST surface the MCP tool invocation so observers can audit the trust boundary',
|
|
147
|
+
)).toBe(true);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-node ordering — exercises the `conformance-multi-node` fixture
|
|
3
|
+
* (3-node DAG: a → b → c, all noop) and asserts that node.completed
|
|
4
|
+
* events arrive in topological order via the `sequence` field on the
|
|
5
|
+
* canonical RunEvent shape.
|
|
6
|
+
*
|
|
7
|
+
* Uses `GET /v1/runs/{runId}/events/poll?lastSequence=0&timeout=1` to
|
|
8
|
+
* fetch the full event log after the run terminates. Long-poll
|
|
9
|
+
* `timeout=1` keeps the test fast — terminal runs return immediately
|
|
10
|
+
* because the server has no more events to wait for.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver } from '../lib/driver.js';
|
|
15
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
16
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
17
|
+
|
|
18
|
+
const WORKFLOW_ID = 'conformance-multi-node';
|
|
19
|
+
const SKIP_NO_FIXTURE = !isFixtureAdvertised(WORKFLOW_ID);
|
|
20
|
+
|
|
21
|
+
interface RunEvent {
|
|
22
|
+
readonly eventId: string;
|
|
23
|
+
readonly runId: string;
|
|
24
|
+
readonly nodeId?: string;
|
|
25
|
+
readonly type: string;
|
|
26
|
+
readonly sequence: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe.skipIf(SKIP_NO_FIXTURE)('multi-node: conformance-multi-node fixture emits node.completed in topological order', () => {
|
|
30
|
+
it('a, b, c node.completed events arrive in DAG order by sequence', async () => {
|
|
31
|
+
const create = await driver.post('/v1/runs', { workflowId: WORKFLOW_ID });
|
|
32
|
+
expect(create.status).toBe(201);
|
|
33
|
+
const runId = (create.json as { runId: string }).runId;
|
|
34
|
+
|
|
35
|
+
const terminal = await pollUntilTerminal(runId);
|
|
36
|
+
expect(terminal.status, driver.describe(
|
|
37
|
+
'fixtures.md conformance-multi-node §Terminal status',
|
|
38
|
+
'fixture MUST reach terminal `completed`',
|
|
39
|
+
)).toBe('completed');
|
|
40
|
+
|
|
41
|
+
const eventsRes = await driver.get(
|
|
42
|
+
`/v1/runs/${encodeURIComponent(runId)}/events/poll?lastSequence=0&timeout=1`,
|
|
43
|
+
);
|
|
44
|
+
expect(eventsRes.status, driver.describe(
|
|
45
|
+
'rest-endpoints.md GET /v1/runs/{runId}/events/poll',
|
|
46
|
+
'event-poll MUST return 200 for known runs',
|
|
47
|
+
)).toBe(200);
|
|
48
|
+
|
|
49
|
+
const events = (eventsRes.json as { events?: RunEvent[] } | undefined)?.events ?? [];
|
|
50
|
+
const nodeCompletions = events
|
|
51
|
+
.filter((e) => e.type === 'node.completed')
|
|
52
|
+
.sort((x, y) => x.sequence - y.sequence)
|
|
53
|
+
.map((e) => e.nodeId);
|
|
54
|
+
|
|
55
|
+
expect(nodeCompletions, driver.describe(
|
|
56
|
+
'fixtures.md conformance-multi-node §Topology',
|
|
57
|
+
'all three node.completed events (a, b, c) MUST be present',
|
|
58
|
+
)).toEqual(['a', 'b', 'c']);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Track 13: multi-region idempotency capability shape (idempotency.md v1.1).
|
|
3
|
+
*
|
|
4
|
+
* Verifies that hosts advertising the multi-region idempotency annex
|
|
5
|
+
* surface a valid `capabilities.idempotency.crossRegion` value. The
|
|
6
|
+
* end-to-end partition behavior cannot be exercised black-box; this
|
|
7
|
+
* scenario validates the discovery-document shape so clients can rely
|
|
8
|
+
* on the capability for routing decisions.
|
|
9
|
+
*
|
|
10
|
+
* @see spec/v1/idempotency.md §"Multi-region idempotency"
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { driver } from '../lib/driver.js';
|
|
15
|
+
|
|
16
|
+
const ALLOWED = new Set(['single-region', 'best-effort', 'strict']);
|
|
17
|
+
|
|
18
|
+
interface IdempotencyCaps {
|
|
19
|
+
supported?: boolean;
|
|
20
|
+
layer1RetentionSeconds?: number;
|
|
21
|
+
layer2RetentionSeconds?: number;
|
|
22
|
+
crossRegion?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('multi-region-idempotency: capability shape', () => {
|
|
26
|
+
it('idempotency.crossRegion (when advertised) MUST be one of the closed enum', async () => {
|
|
27
|
+
const disco = await driver.get('/.well-known/openwop');
|
|
28
|
+
const idem =
|
|
29
|
+
(disco.json as { capabilities?: { idempotency?: IdempotencyCaps } }).capabilities
|
|
30
|
+
?.idempotency;
|
|
31
|
+
|
|
32
|
+
if (!idem || idem.crossRegion === undefined) {
|
|
33
|
+
// eslint-disable-next-line no-console
|
|
34
|
+
console.warn(
|
|
35
|
+
'[multi-region-idempotency] capabilities.idempotency.crossRegion not advertised; skipping',
|
|
36
|
+
);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
expect(ALLOWED.has(idem.crossRegion), driver.describe(
|
|
41
|
+
'idempotency.md §"Multi-region idempotency" §"Capability advertisement"',
|
|
42
|
+
'crossRegion MUST be one of {"single-region","best-effort","strict"}',
|
|
43
|
+
)).toBe(true);
|
|
44
|
+
|
|
45
|
+
if (idem.layer1RetentionSeconds !== undefined) {
|
|
46
|
+
expect(idem.layer1RetentionSeconds).toBeGreaterThan(0);
|
|
47
|
+
}
|
|
48
|
+
if (idem.layer2RetentionSeconds !== undefined) {
|
|
49
|
+
expect(idem.layer2RetentionSeconds).toBeGreaterThan(0);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Agent Shift Phase 5 — CP-1 conservative-path orchestrator suspend.
|
|
3
|
+
*
|
|
4
|
+
* Verifies the CP-1 invariant: when a `core.orchestrator.supervisor`
|
|
5
|
+
* would emit a decision with `confidence < escalationThreshold`, the
|
|
6
|
+
* host MUST:
|
|
7
|
+
* 1. Hold the decision (do NOT emit runOrchestrator.decided).
|
|
8
|
+
* 2. Suspend via `node.suspended { reason: 'low-confidence' }`.
|
|
9
|
+
* 3. Transition run to `'waiting-approval'`.
|
|
10
|
+
* 4. After human resume, emit ONE `runOrchestrator.decided` carrying
|
|
11
|
+
* the operator-ratified decision plus the supervisor's agentId.
|
|
12
|
+
*
|
|
13
|
+
* Capability-gated: skips when host doesn't advertise
|
|
14
|
+
* `capabilities.agents.orchestrator: true`. Fixture-gated: requires
|
|
15
|
+
* `conformance-orchestrator-low-confidence`.
|
|
16
|
+
*
|
|
17
|
+
* @see spec/v1/interrupt.md §`low-confidence`
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { driver } from '../lib/driver.js';
|
|
22
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
23
|
+
import { isOrchestratorSupported } from '../lib/multi-agent-capabilities.js';
|
|
24
|
+
|
|
25
|
+
const FIXTURE = 'conformance-orchestrator-low-confidence';
|
|
26
|
+
const SKIP = !isOrchestratorSupported() || !isFixtureAdvertised(FIXTURE);
|
|
27
|
+
|
|
28
|
+
describe.skipIf(SKIP)('orchestratorConservativePath: CP-1 low-confidence suspend', () => {
|
|
29
|
+
it('supervisor below threshold suspends with reason=low-confidence; ratified decision follows after resume', async () => {
|
|
30
|
+
const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
|
|
31
|
+
expect(create.status).toBe(201);
|
|
32
|
+
const runId = (create.json as { runId: string }).runId;
|
|
33
|
+
|
|
34
|
+
// Wait for the run to enter waiting-approval.
|
|
35
|
+
let status: string | undefined;
|
|
36
|
+
for (let i = 0; i < 50; i++) {
|
|
37
|
+
const res = await driver.get(`/v1/runs/${encodeURIComponent(runId)}`);
|
|
38
|
+
status = (res.json as { status: string }).status;
|
|
39
|
+
if (status === 'waiting-approval' || status === 'failed' || status === 'completed') break;
|
|
40
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
41
|
+
}
|
|
42
|
+
expect(status).toBe('waiting-approval');
|
|
43
|
+
|
|
44
|
+
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
45
|
+
const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> })
|
|
46
|
+
.events ?? [];
|
|
47
|
+
|
|
48
|
+
// Before resume: no runOrchestrator.decided emitted yet (the decision
|
|
49
|
+
// was held per CP-1 step 1).
|
|
50
|
+
const decisionsBeforeResume = list.filter((e) => e.type === 'runOrchestrator.decided');
|
|
51
|
+
expect(
|
|
52
|
+
decisionsBeforeResume.length,
|
|
53
|
+
'CP-1: low-confidence holds the decision until human ratification',
|
|
54
|
+
).toBe(0);
|
|
55
|
+
|
|
56
|
+
// node.suspended with reason=low-confidence is present.
|
|
57
|
+
const lowConfSuspend = list.find(
|
|
58
|
+
(e) => e.type === 'node.suspended' && e.payload?.reason === 'low-confidence',
|
|
59
|
+
);
|
|
60
|
+
expect(lowConfSuspend).toBeDefined();
|
|
61
|
+
expect(typeof lowConfSuspend!.payload?.agentId).toBe('string');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-Agent Shift Phase 5 — orchestrator → dispatch → next-worker round-trip.
|
|
3
|
+
*
|
|
4
|
+
* Verifies that a workflow with `core.orchestrator.supervisor` →
|
|
5
|
+
* `core.dispatch` topology emits the canonical event sequence:
|
|
6
|
+
* `node.started{supervisor}` → `runOrchestrator.decided{next-worker}`
|
|
7
|
+
* → `node.completed{supervisor}` → `node.started{dispatch}` → child-run
|
|
8
|
+
* lifecycle → `node.completed{dispatch}`.
|
|
9
|
+
*
|
|
10
|
+
* The supervisor's `runOrchestrator.decided` payload conforms to
|
|
11
|
+
* `schemas/run-orchestrator-decided-event.schema.json` + nested
|
|
12
|
+
* `schemas/orchestrator-decision.schema.json`.
|
|
13
|
+
*
|
|
14
|
+
* Capability-gated: skips when host doesn't advertise
|
|
15
|
+
* `capabilities.agents.orchestrator: true` AND `capabilities.agents.dispatch: true`.
|
|
16
|
+
* Fixture-gated: requires `conformance-orchestrator-dispatch`.
|
|
17
|
+
*
|
|
18
|
+
* @see schemas/orchestrator-decision.schema.json
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { describe, it, expect } from 'vitest';
|
|
22
|
+
import { driver } from '../lib/driver.js';
|
|
23
|
+
import { pollUntilTerminal } from '../lib/polling.js';
|
|
24
|
+
import { isFixtureAdvertised } from '../lib/fixtures.js';
|
|
25
|
+
import {
|
|
26
|
+
isOrchestratorSupported,
|
|
27
|
+
isDispatchSupported,
|
|
28
|
+
} from '../lib/multi-agent-capabilities.js';
|
|
29
|
+
|
|
30
|
+
const FIXTURE = 'conformance-orchestrator-dispatch';
|
|
31
|
+
const SKIP =
|
|
32
|
+
!isOrchestratorSupported() ||
|
|
33
|
+
!isDispatchSupported() ||
|
|
34
|
+
!isFixtureAdvertised(FIXTURE);
|
|
35
|
+
|
|
36
|
+
describe.skipIf(SKIP)('orchestratorDispatch: supervisor → dispatch → next-worker', () => {
|
|
37
|
+
it('emits runOrchestrator.decided{next-worker} between supervisor + dispatch', async () => {
|
|
38
|
+
const create = await driver.post('/v1/runs', { workflowId: FIXTURE });
|
|
39
|
+
expect(create.status).toBe(201);
|
|
40
|
+
const runId = (create.json as { runId: string }).runId;
|
|
41
|
+
|
|
42
|
+
const terminal = await pollUntilTerminal(runId);
|
|
43
|
+
expect(terminal.status).toBe('completed');
|
|
44
|
+
|
|
45
|
+
const events = await driver.get(`/v1/runs/${encodeURIComponent(runId)}/events`);
|
|
46
|
+
const list = (events.json as { events?: Array<{ type: string; payload?: Record<string, unknown> }> })
|
|
47
|
+
.events ?? [];
|
|
48
|
+
|
|
49
|
+
const decisions = list.filter((e) => e.type === 'runOrchestrator.decided');
|
|
50
|
+
expect(decisions.length).toBeGreaterThan(0);
|
|
51
|
+
|
|
52
|
+
// At least one decision must be kind:'next-worker' (the dispatched-worker case).
|
|
53
|
+
const nextWorker = decisions.find((e) => {
|
|
54
|
+
const d = e.payload?.decision as { kind?: string } | undefined;
|
|
55
|
+
return d?.kind === 'next-worker';
|
|
56
|
+
});
|
|
57
|
+
expect(nextWorker, 'fixture emits at least one kind:next-worker decision').toBeDefined();
|
|
58
|
+
|
|
59
|
+
const payload = nextWorker!.payload!;
|
|
60
|
+
expect(typeof payload.agentId).toBe('string');
|
|
61
|
+
const decision = payload.decision as { kind: string; nextWorkerIds: string[] };
|
|
62
|
+
expect(decision.kind).toBe('next-worker');
|
|
63
|
+
expect(Array.isArray(decision.nextWorkerIds)).toBe(true);
|
|
64
|
+
expect(decision.nextWorkerIds.length).toBeGreaterThanOrEqual(1);
|
|
65
|
+
});
|
|
66
|
+
});
|