@a5c-ai/kradle 5.0.1-staging.3abdf9534c25
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/Dockerfile +31 -0
- package/README.md +187 -0
- package/bin/kradle-demo.mjs +23 -0
- package/bin/kradle-server.mjs +14 -0
- package/dist/kradle-controller-ui.json +3482 -0
- package/dist/kradle-lifecycle.json +201 -0
- package/dist/kradle-runtime-snapshot.json +3125 -0
- package/dist/kradle-summary.json +724 -0
- package/docs/README.md +61 -0
- package/docs/agents/README.md +83 -0
- package/docs/agents/acceptance-test-matrix.md +193 -0
- package/docs/agents/agent-mux-adapter-contract.md +167 -0
- package/docs/agents/agent-mux-source-map.md +310 -0
- package/docs/agents/agent-run-memory-import-spec.md +256 -0
- package/docs/agents/agent-stack-management-spec.md +421 -0
- package/docs/agents/api-contract-spec.md +309 -0
- package/docs/agents/artifacts-writeback-spec.md +145 -0
- package/docs/agents/chart-packaging-spec.md +128 -0
- package/docs/agents/ci-orchestration-spec.md +140 -0
- package/docs/agents/context-assembly-spec.md +219 -0
- package/docs/agents/controller-reconciliation-spec.md +255 -0
- package/docs/agents/crd-schema-spec.md +315 -0
- package/docs/agents/decision-log-open-questions.md +169 -0
- package/docs/agents/developer-implementation-checklist.md +329 -0
- package/docs/agents/dispatching-design.md +262 -0
- package/docs/agents/gaps-agent-mux-to-kradle-crds.md +298 -0
- package/docs/agents/glossary.md +66 -0
- package/docs/agents/implementation-blueprint.md +324 -0
- package/docs/agents/implementation-rollout-slices.md +251 -0
- package/docs/agents/memory-context-integration-spec.md +194 -0
- package/docs/agents/memory-ontology-schema-spec.md +253 -0
- package/docs/agents/memory-operations-runbook.md +121 -0
- package/docs/agents/mvp-vertical-slice-spec.md +146 -0
- package/docs/agents/observability-audit-spec.md +265 -0
- package/docs/agents/operator-runbook.md +174 -0
- package/docs/agents/org-memory-api-payload-examples.md +333 -0
- package/docs/agents/org-memory-controller-sequence-spec.md +181 -0
- package/docs/agents/org-memory-e2e-fixture-plan.md +161 -0
- package/docs/agents/org-memory-ui-implementation-map.md +114 -0
- package/docs/agents/org-memory-vertical-slice-spec.md +168 -0
- package/docs/agents/org-resource-model-delta-spec.md +111 -0
- package/docs/agents/org-route-resource-model-spec.md +183 -0
- package/docs/agents/org-scoping-namespace-spec.md +114 -0
- package/docs/agents/rbac-secrets-management-spec.md +406 -0
- package/docs/agents/repository-page-integration-spec.md +255 -0
- package/docs/agents/resource-contract-examples.md +808 -0
- package/docs/agents/resource-relationship-map.md +190 -0
- package/docs/agents/security-threat-model.md +188 -0
- package/docs/agents/shared-memory-company-brain-spec.md +358 -0
- package/docs/agents/storage-migration-spec.md +168 -0
- package/docs/agents/subagent-orchestration-spec.md +152 -0
- package/docs/agents/system-overview.md +88 -0
- package/docs/agents/tools-mcp-skills-spec.md +189 -0
- package/docs/agents/traceability-matrix.md +79 -0
- package/docs/agents/ui-flow-spec.md +211 -0
- package/docs/agents/ui-ux-system-spec.md +426 -0
- package/docs/agents/workspace-lifecycle-spec.md +166 -0
- package/docs/architecture-spec.md +78 -0
- package/docs/architecture-v2.md +2759 -0
- package/docs/components/control-plane.md +78 -0
- package/docs/components/data-plane.md +69 -0
- package/docs/components/hooks-events.md +67 -0
- package/docs/components/identity-rbac-policy.md +73 -0
- package/docs/components/kubevela-oam.md +70 -0
- package/docs/components/operations-publishing.md +81 -0
- package/docs/components/runners-ci.md +66 -0
- package/docs/components/web-ui.md +94 -0
- package/docs/crd-behaviors-and-relationships.md +3926 -0
- package/docs/external/README.md +47 -0
- package/docs/external/bidirectional-sync-design.md +134 -0
- package/docs/external/cicd-interface.md +64 -0
- package/docs/external/external-backend-controllers.md +170 -0
- package/docs/external/external-backend-crds.md +234 -0
- package/docs/external/external-backend-ui-spec.md +151 -0
- package/docs/external/external-backend-ux-flows.md +115 -0
- package/docs/external/external-object-mapping.md +125 -0
- package/docs/external/git-forge-interface.md +68 -0
- package/docs/external/github-integration-design.md +151 -0
- package/docs/external/issue-tracking-interface.md +66 -0
- package/docs/external/provider-capability-manifests.md +204 -0
- package/docs/external/provider-catalog.md +139 -0
- package/docs/external/provider-rollout-testing.md +78 -0
- package/docs/external/research-results.md +48 -0
- package/docs/external/security-auth-permissions.md +81 -0
- package/docs/external/sync-state-machines.md +108 -0
- package/docs/external/unified-external-backend-model.md +107 -0
- package/docs/external/user-facing-changes.md +67 -0
- package/docs/gaps.md +161 -0
- package/docs/install.md +94 -0
- package/docs/integration-and-design-decisions.md +1530 -0
- package/docs/kradle-design.md +334 -0
- package/docs/local-minikube.md +55 -0
- package/docs/ontology/README.md +32 -0
- package/docs/ontology/bounded-contexts.md +29 -0
- package/docs/ontology/events-and-hooks.md +32 -0
- package/docs/ontology/oam-kubevela.md +32 -0
- package/docs/ontology/operations-and-release.md +25 -0
- package/docs/ontology/personas-and-actors.md +32 -0
- package/docs/ontology/policies-and-invariants.md +33 -0
- package/docs/ontology/problem-space.md +30 -0
- package/docs/ontology/resource-contracts.md +40 -0
- package/docs/ontology/resource-taxonomy.md +42 -0
- package/docs/ontology/runners-and-ci.md +29 -0
- package/docs/ontology/solution-space.md +24 -0
- package/docs/ontology/storage-and-data-boundaries.md +29 -0
- package/docs/ontology/validation-matrix.md +24 -0
- package/docs/ontology/web-ui-excellent-flows.md +32 -0
- package/docs/ontology/workflows.md +39 -0
- package/docs/ontology/world.md +35 -0
- package/docs/openapi.yaml +1291 -0
- package/docs/product-requirements.md +62 -0
- package/docs/requirements-v2.md +235 -0
- package/docs/roadmap-mvp.md +87 -0
- package/docs/sdk-api-reference.md +1108 -0
- package/docs/system-requirements.md +90 -0
- package/docs/system-spec-v2.md +1230 -0
- package/docs/tests/README.md +53 -0
- package/docs/tests/agent-qa-plan.md +63 -0
- package/docs/tests/browser-ui-tests.md +62 -0
- package/docs/tests/ci-quality-gates.md +48 -0
- package/docs/tests/coverage-model.md +64 -0
- package/docs/tests/e2e-scenario-tests.md +53 -0
- package/docs/tests/fixtures-test-data.md +63 -0
- package/docs/tests/observability-reliability-tests.md +54 -0
- package/docs/tests/product-test-matrix.md +145 -0
- package/docs/tests/qa-adoption-roadmap.md +130 -0
- package/docs/tests/qa-automation-plan.md +101 -0
- package/docs/tests/security-compliance-tests.md +57 -0
- package/docs/tests/test-framework-tools.md +88 -0
- package/docs/tests/test-suite-layout.md +121 -0
- package/docs/tests/unit-integration-tests.md +48 -0
- package/docs/todo-kyverno +714 -0
- package/docs/todos.md +4 -0
- package/docs/user-stories.md +78 -0
- package/docs/web-console-spec.md +533 -0
- package/examples/minikube-demo.yaml +190 -0
- package/examples/oam-application.yaml +23 -0
- package/examples/policy-kyverno-pr-title.yaml +18 -0
- package/package.json +66 -0
- package/scripts/build.mjs +29 -0
- package/scripts/setup-minikube.mjs +65 -0
- package/scripts/smoke.mjs +37 -0
- package/scripts/validate-doc-coverage.mjs +152 -0
- package/scripts/validate-package.mjs +95 -0
- package/scripts/validate-ui.mjs +305 -0
- package/src/agent-adapter-controller.js +169 -0
- package/src/agent-approval-controller.js +170 -0
- package/src/agent-context-bundles.js +242 -0
- package/src/agent-dispatch-controller.js +549 -0
- package/src/agent-gateway-config-controller.js +147 -0
- package/src/agent-identity-migration.js +115 -0
- package/src/agent-memory-controller.js +357 -0
- package/src/agent-memory-import.js +327 -0
- package/src/agent-memory-query.js +292 -0
- package/src/agent-memory-repository-source-controller.js +255 -0
- package/src/agent-mux-client.js +589 -0
- package/src/agent-permission-review.js +250 -0
- package/src/agent-persona-controller.js +135 -0
- package/src/agent-project-controller.js +117 -0
- package/src/agent-prompt-composition.js +55 -0
- package/src/agent-provider-config-controller.js +151 -0
- package/src/agent-secret-config-grant-controller.js +282 -0
- package/src/agent-session-transcript-controller.js +189 -0
- package/src/agent-stack-controller.js +421 -0
- package/src/agent-subagent-controller.js +160 -0
- package/src/agent-transport-binding-controller.js +121 -0
- package/src/agent-trigger-controller.js +387 -0
- package/src/agent-workspace-controller.js +702 -0
- package/src/agent-writeback-controller.js +302 -0
- package/src/api-controller.js +621 -0
- package/src/argocd-gitops.js +43 -0
- package/src/artifact-registry-controller.js +542 -0
- package/src/assistant-runtime.js +284 -0
- package/src/async-controller.js +207 -0
- package/src/audit-controller.js +191 -0
- package/src/auth.js +310 -0
- package/src/component-catalog.js +41 -0
- package/src/control-plane.js +136 -0
- package/src/controller-client.js +112 -0
- package/src/controller-ui.js +620 -0
- package/src/data-plane.js +179 -0
- package/src/event-bus.js +397 -0
- package/src/external/conflict-controller.js +225 -0
- package/src/external/github/auth.js +96 -0
- package/src/external/github/cicd.js +180 -0
- package/src/external/github/git-forge.js +240 -0
- package/src/external/github/index.js +144 -0
- package/src/external/github/issue-tracking.js +163 -0
- package/src/external/provider-adapter.js +161 -0
- package/src/external/provider-resource-factory.js +221 -0
- package/src/external/sync-controller.js +235 -0
- package/src/external/webhook-controller.js +144 -0
- package/src/external/write-controller.js +283 -0
- package/src/gitea-backend.js +131 -0
- package/src/gitea-service.js +173 -0
- package/src/handoff.js +98 -0
- package/src/health-probes.js +134 -0
- package/src/hooks-events.js +63 -0
- package/src/hooks-lifecycle.js +117 -0
- package/src/http-server.js +409 -0
- package/src/identity-policy.js +86 -0
- package/src/index.js +71 -0
- package/src/jitsi-agent-bridge.js +141 -0
- package/src/jitsi-meeting-controller.js +291 -0
- package/src/jitsi-sync-controller.js +198 -0
- package/src/kradle-inference-service-controller.js +246 -0
- package/src/kubernetes-controller-async.js +531 -0
- package/src/kubernetes-controller.js +904 -0
- package/src/kubernetes-resource-gateway.js +48 -0
- package/src/model-route-controller.js +364 -0
- package/src/notification-controller.js +178 -0
- package/src/operations.js +112 -0
- package/src/org-scoping.js +5 -0
- package/src/resource-model.js +282 -0
- package/src/runner-controller.js +272 -0
- package/src/runners-ci.js +48 -0
- package/src/runtime.js +196 -0
- package/src/snapshot-cache.js +157 -0
- package/src/virtual-model-controller.js +538 -0
- package/src/virtual-model-hook-bridge.js +200 -0
- package/src/web-ui.js +40 -0
- package/tests/agent-adapter-controller.test.js +361 -0
- package/tests/agent-approval-controller.test.js +173 -0
- package/tests/agent-context-bundles.test.js +278 -0
- package/tests/agent-dispatch-controller.test.js +679 -0
- package/tests/agent-gateway-config-controller.test.js +386 -0
- package/tests/agent-identity-migration.test.js +87 -0
- package/tests/agent-memory-controller.test.js +461 -0
- package/tests/agent-memory-import-snapshot.test.js +477 -0
- package/tests/agent-memory-query.test.js +404 -0
- package/tests/agent-memory-repository-source.test.js +514 -0
- package/tests/agent-mux-client.test.js +389 -0
- package/tests/agent-mux-integration.test.js +971 -0
- package/tests/agent-permission-review-v2.test.js +317 -0
- package/tests/agent-permission-review.test.js +209 -0
- package/tests/agent-persona-controller.test.js +127 -0
- package/tests/agent-project-controller.test.js +302 -0
- package/tests/agent-prompt-composition.test.js +76 -0
- package/tests/agent-provider-config-controller.test.js +376 -0
- package/tests/agent-resources.test.js +303 -0
- package/tests/agent-secret-config-grant.test.js +231 -0
- package/tests/agent-session-transcript-controller.test.js +499 -0
- package/tests/agent-stack-controller.test.js +283 -0
- package/tests/agent-subagent-controller.test.js +201 -0
- package/tests/agent-transport-binding-controller.test.js +294 -0
- package/tests/agent-trigger-controller.test.js +271 -0
- package/tests/agent-trigger-routes.test.js +190 -0
- package/tests/agent-trigger-sources.test.js +245 -0
- package/tests/agent-workspace-controller.test.js +181 -0
- package/tests/agent-writeback.test.js +292 -0
- package/tests/approval-persistence.test.js +171 -0
- package/tests/artifact-registry.test.js +511 -0
- package/tests/assistant-runtime.test.js +506 -0
- package/tests/async-controller.test.js +252 -0
- package/tests/audit-controller.test.js +227 -0
- package/tests/codespace-controller.test.js +318 -0
- package/tests/controller-client.test.js +133 -0
- package/tests/deployment.test.js +527 -0
- package/tests/e2e/lifecycle.test.js +120 -0
- package/tests/event-bus-integration.test.js +355 -0
- package/tests/external-github-forge.test.js +560 -0
- package/tests/external-github-issues-cicd.test.js +520 -0
- package/tests/external-integration.test.js +470 -0
- package/tests/external-persistence.test.js +415 -0
- package/tests/external-provider-adapter.test.js +365 -0
- package/tests/external-resource-model.test.js +223 -0
- package/tests/external-webhook-sync.test.js +287 -0
- package/tests/external-write-conflict.test.js +353 -0
- package/tests/gitea-service.test.js +253 -0
- package/tests/health-check-real.test.js +165 -0
- package/tests/health-probes.test.js +90 -0
- package/tests/hooks-lifecycle.test.js +364 -0
- package/tests/integration/full-flow.test.js +266 -0
- package/tests/jitsi-agent-bridge.test.js +119 -0
- package/tests/jitsi-helm-integration.test.js +77 -0
- package/tests/jitsi-meeting-controller.test.js +170 -0
- package/tests/jitsi-resource-model.test.js +73 -0
- package/tests/jitsi-sync-controller.test.js +112 -0
- package/tests/kradle-inference-service.test.js +689 -0
- package/tests/kradle.test.js +779 -0
- package/tests/memory-search-wiring.test.js +270 -0
- package/tests/model-route-controller.test.js +733 -0
- package/tests/notification-controller.test.js +196 -0
- package/tests/notification-integration.test.js +179 -0
- package/tests/org-scoping.test.js +687 -0
- package/tests/runner-controller.test.js +327 -0
- package/tests/runner-integration.test.js +231 -0
- package/tests/session-cookie-hmac.test.js +151 -0
- package/tests/snapshot-performance.test.js +315 -0
- package/tests/sse-events.test.js +107 -0
- package/tests/virtual-model-controller.test.js +877 -0
- package/tests/virtual-model-hook-bridge.test.js +384 -0
- package/tests/webhook-trigger.test.js +198 -0
- package/tests/workspace-volumes.test.js +312 -0
- package/tests/writeback-persistence.test.js +207 -0
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import {
|
|
4
|
+
createVirtualModelController,
|
|
5
|
+
validateVirtualModel,
|
|
6
|
+
createResource,
|
|
7
|
+
createEventBus,
|
|
8
|
+
VIRTUAL_MODEL_CONTROLLER_BOUNDARY
|
|
9
|
+
} from '../src/index.js';
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// KradleVirtualModel Controller Tests
|
|
13
|
+
//
|
|
14
|
+
// A KradleVirtualModel provides a programmable model abstraction with declarative
|
|
15
|
+
// routing rules, JS hooks for request/response transformation, session lifecycle
|
|
16
|
+
// management, and observability injection.
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
function makeVirtualModel(name, overrides = {}) {
|
|
20
|
+
return createResource('KradleVirtualModel', { name, namespace: 'kradle-org-default' }, {
|
|
21
|
+
organizationRef: 'default',
|
|
22
|
+
modelName: `model-${name}`,
|
|
23
|
+
routes: [
|
|
24
|
+
{ modelRouteRef: 'route-primary', weight: 80 },
|
|
25
|
+
{ modelRouteRef: 'route-secondary', weight: 20 }
|
|
26
|
+
],
|
|
27
|
+
...overrides
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function makeModelRoute(name) {
|
|
32
|
+
return {
|
|
33
|
+
kind: 'KradleModelRoute',
|
|
34
|
+
metadata: { name, namespace: 'kradle-org-default' },
|
|
35
|
+
spec: { organizationRef: 'default', modelName: name, routeType: 'external' }
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// 1. Factory and shape
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
test('createVirtualModelController returns controller with expected methods', () => {
|
|
44
|
+
const controller = createVirtualModelController();
|
|
45
|
+
assert.ok(controller, 'controller must be truthy');
|
|
46
|
+
assert.equal(typeof controller.validate, 'function', 'must expose validate');
|
|
47
|
+
assert.equal(typeof controller.evaluateRules, 'function', 'must expose evaluateRules');
|
|
48
|
+
assert.equal(typeof controller.executeHook, 'function', 'must expose executeHook');
|
|
49
|
+
assert.equal(typeof controller.resolveRoute, 'function', 'must expose resolveRoute');
|
|
50
|
+
assert.equal(typeof controller.transformRequest, 'function', 'must expose transformRequest');
|
|
51
|
+
assert.equal(typeof controller.transformResponse, 'function', 'must expose transformResponse');
|
|
52
|
+
assert.equal(typeof controller.handleSessionEvent, 'function', 'must expose handleSessionEvent');
|
|
53
|
+
assert.equal(typeof controller.emitObservability, 'function', 'must expose emitObservability');
|
|
54
|
+
assert.equal(typeof controller.reconcileVirtualModels, 'function', 'must expose reconcileVirtualModels');
|
|
55
|
+
assert.equal(controller.role, 'virtual-model-controller', 'must declare its role');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// 2. VIRTUAL_MODEL_CONTROLLER_BOUNDARY exported correctly
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
test('VIRTUAL_MODEL_CONTROLLER_BOUNDARY is exported with correct role', () => {
|
|
63
|
+
assert.ok(VIRTUAL_MODEL_CONTROLLER_BOUNDARY, 'BOUNDARY must be exported');
|
|
64
|
+
assert.equal(VIRTUAL_MODEL_CONTROLLER_BOUNDARY.role, 'virtual-model-controller', 'role must match');
|
|
65
|
+
assert.ok(Array.isArray(VIRTUAL_MODEL_CONTROLLER_BOUNDARY.owns), 'must declare owned concerns');
|
|
66
|
+
assert.ok(VIRTUAL_MODEL_CONTROLLER_BOUNDARY.owns.includes('virtual model validation'), 'must own validation');
|
|
67
|
+
assert.ok(VIRTUAL_MODEL_CONTROLLER_BOUNDARY.owns.includes('route resolution'), 'must own route resolution');
|
|
68
|
+
assert.ok(Array.isArray(VIRTUAL_MODEL_CONTROLLER_BOUNDARY.mustNotOwn), 'must declare mustNotOwn');
|
|
69
|
+
assert.ok(VIRTUAL_MODEL_CONTROLLER_BOUNDARY.mustNotOwn.includes('secret values'), 'must not own secret values');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// 3. validate — accepts valid virtual model
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
test('validate accepts valid virtual model', () => {
|
|
77
|
+
const controller = createVirtualModelController();
|
|
78
|
+
const vm = makeVirtualModel('valid');
|
|
79
|
+
const result = controller.validate(vm);
|
|
80
|
+
|
|
81
|
+
assert.equal(result.valid, true, 'valid virtual model must pass validation');
|
|
82
|
+
assert.equal(result.errors.length, 0, 'errors array must be empty');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// 4. validate — rejects null resource
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
test('validate rejects null resource', () => {
|
|
90
|
+
const controller = createVirtualModelController();
|
|
91
|
+
const result = controller.validate(null);
|
|
92
|
+
|
|
93
|
+
assert.equal(result.valid, false, 'null resource must fail validation');
|
|
94
|
+
assert.ok(result.errors.some(e => /null|undefined/i.test(e)), 'error must mention null or undefined');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// 5. validate — rejects missing organizationRef
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
test('validate rejects missing organizationRef', () => {
|
|
102
|
+
const controller = createVirtualModelController();
|
|
103
|
+
const vm = makeVirtualModel('no-org');
|
|
104
|
+
delete vm.spec.organizationRef;
|
|
105
|
+
const result = controller.validate(vm);
|
|
106
|
+
|
|
107
|
+
assert.equal(result.valid, false, 'missing organizationRef must fail');
|
|
108
|
+
assert.ok(result.errors.some(e => /organizationRef/i.test(e)), 'error must mention organizationRef');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// 6. validate — rejects missing modelName
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
test('validate rejects missing modelName', () => {
|
|
116
|
+
const controller = createVirtualModelController();
|
|
117
|
+
const vm = makeVirtualModel('no-model');
|
|
118
|
+
delete vm.spec.modelName;
|
|
119
|
+
const result = controller.validate(vm);
|
|
120
|
+
|
|
121
|
+
assert.equal(result.valid, false, 'missing modelName must fail');
|
|
122
|
+
assert.ok(result.errors.some(e => /modelName/i.test(e)), 'error must mention modelName');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// 7. validate — rejects missing routes
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
test('validate rejects missing routes', () => {
|
|
130
|
+
const controller = createVirtualModelController();
|
|
131
|
+
const vm = makeVirtualModel('no-routes');
|
|
132
|
+
delete vm.spec.routes;
|
|
133
|
+
const result = controller.validate(vm);
|
|
134
|
+
|
|
135
|
+
assert.equal(result.valid, false, 'missing routes must fail');
|
|
136
|
+
assert.ok(result.errors.some(e => /routes/i.test(e)), 'error must mention routes');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// 8. validate — rejects empty routes array
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
test('validate rejects empty routes array', () => {
|
|
144
|
+
const controller = createVirtualModelController();
|
|
145
|
+
const vm = makeVirtualModel('empty-routes', { routes: [] });
|
|
146
|
+
const result = controller.validate(vm);
|
|
147
|
+
|
|
148
|
+
assert.equal(result.valid, false, 'empty routes must fail');
|
|
149
|
+
assert.ok(result.errors.some(e => /routes/i.test(e)), 'error must mention routes');
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// 9. validate — rejects route without modelRouteRef
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
test('validate rejects route without modelRouteRef', () => {
|
|
157
|
+
const controller = createVirtualModelController();
|
|
158
|
+
const vm = makeVirtualModel('bad-route', {
|
|
159
|
+
routes: [{ weight: 100 }]
|
|
160
|
+
});
|
|
161
|
+
const result = controller.validate(vm);
|
|
162
|
+
|
|
163
|
+
assert.equal(result.valid, false, 'route without modelRouteRef must fail');
|
|
164
|
+
assert.ok(result.errors.some(e => /modelRouteRef/i.test(e)), 'error must mention modelRouteRef');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// 10. validate — rejects rule with invalid operator
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
test('validate rejects rule with invalid operator', () => {
|
|
172
|
+
const controller = createVirtualModelController();
|
|
173
|
+
const vm = makeVirtualModel('bad-rule', {
|
|
174
|
+
rules: [{
|
|
175
|
+
name: 'bad-op',
|
|
176
|
+
conditions: [{ field: 'tokenCount', operator: 'invalid', value: 100 }],
|
|
177
|
+
action: { route: 'route-a' }
|
|
178
|
+
}]
|
|
179
|
+
});
|
|
180
|
+
const result = controller.validate(vm);
|
|
181
|
+
|
|
182
|
+
assert.equal(result.valid, false, 'invalid operator must fail');
|
|
183
|
+
assert.ok(result.errors.some(e => /operator/i.test(e)), 'error must mention operator');
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
// 11. validate — validates rules with missing action.route
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
test('validate rejects rule with missing action.route', () => {
|
|
191
|
+
const controller = createVirtualModelController();
|
|
192
|
+
const vm = makeVirtualModel('no-action', {
|
|
193
|
+
rules: [{
|
|
194
|
+
name: 'no-route',
|
|
195
|
+
conditions: [{ field: 'tokenCount', operator: 'gt', value: 100 }],
|
|
196
|
+
action: {}
|
|
197
|
+
}]
|
|
198
|
+
});
|
|
199
|
+
const result = controller.validate(vm);
|
|
200
|
+
|
|
201
|
+
assert.equal(result.valid, false, 'missing action.route must fail');
|
|
202
|
+
assert.ok(result.errors.some(e => /action\.route/i.test(e)), 'error must mention action.route');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// 12. evaluateRules — returns matching rule
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
test('evaluateRules returns matching rule for eq operator', () => {
|
|
210
|
+
const controller = createVirtualModelController();
|
|
211
|
+
const rules = [{
|
|
212
|
+
name: 'premium-user',
|
|
213
|
+
conditions: [{ field: 'userRole', operator: 'eq', value: 'premium' }],
|
|
214
|
+
action: { route: 'route-premium' }
|
|
215
|
+
}];
|
|
216
|
+
const result = controller.evaluateRules(rules, { userRole: 'premium' });
|
|
217
|
+
|
|
218
|
+
assert.ok(result, 'must return a result');
|
|
219
|
+
assert.equal(result.matched, true, 'must be matched');
|
|
220
|
+
assert.equal(result.routeRef, 'route-premium', 'must return correct route');
|
|
221
|
+
assert.equal(result.ruleName, 'premium-user', 'must return rule name');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// 13. evaluateRules — returns null when no rules match
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
test('evaluateRules returns null when no rules match', () => {
|
|
229
|
+
const controller = createVirtualModelController();
|
|
230
|
+
const rules = [{
|
|
231
|
+
name: 'high-token',
|
|
232
|
+
conditions: [{ field: 'tokenCount', operator: 'gt', value: 1000 }],
|
|
233
|
+
action: { route: 'route-large' }
|
|
234
|
+
}];
|
|
235
|
+
const result = controller.evaluateRules(rules, { tokenCount: 500 });
|
|
236
|
+
|
|
237
|
+
assert.equal(result, null, 'must return null when no rules match');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
// 14. evaluateRules — supports all comparison operators
|
|
242
|
+
// ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
test('evaluateRules supports gt, lt, gte, lte operators', () => {
|
|
245
|
+
const controller = createVirtualModelController();
|
|
246
|
+
|
|
247
|
+
// gt
|
|
248
|
+
assert.ok(controller.evaluateRules(
|
|
249
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'gt', value: 5 }], action: { route: 'r' } }],
|
|
250
|
+
{ x: 10 }
|
|
251
|
+
)?.matched, 'gt must match');
|
|
252
|
+
|
|
253
|
+
// lt
|
|
254
|
+
assert.ok(controller.evaluateRules(
|
|
255
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'lt', value: 10 }], action: { route: 'r' } }],
|
|
256
|
+
{ x: 5 }
|
|
257
|
+
)?.matched, 'lt must match');
|
|
258
|
+
|
|
259
|
+
// gte
|
|
260
|
+
assert.ok(controller.evaluateRules(
|
|
261
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'gte', value: 5 }], action: { route: 'r' } }],
|
|
262
|
+
{ x: 5 }
|
|
263
|
+
)?.matched, 'gte must match on equal');
|
|
264
|
+
|
|
265
|
+
// lte
|
|
266
|
+
assert.ok(controller.evaluateRules(
|
|
267
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'lte', value: 10 }], action: { route: 'r' } }],
|
|
268
|
+
{ x: 10 }
|
|
269
|
+
)?.matched, 'lte must match on equal');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// 15. evaluateRules — supports neq, in, contains, matches operators
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
test('evaluateRules supports neq, in, contains, matches operators', () => {
|
|
277
|
+
const controller = createVirtualModelController();
|
|
278
|
+
|
|
279
|
+
// neq
|
|
280
|
+
assert.ok(controller.evaluateRules(
|
|
281
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'neq', value: 'a' }], action: { route: 'r' } }],
|
|
282
|
+
{ x: 'b' }
|
|
283
|
+
)?.matched, 'neq must match');
|
|
284
|
+
|
|
285
|
+
// in
|
|
286
|
+
assert.ok(controller.evaluateRules(
|
|
287
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'in', value: ['a', 'b', 'c'] }], action: { route: 'r' } }],
|
|
288
|
+
{ x: 'b' }
|
|
289
|
+
)?.matched, 'in must match');
|
|
290
|
+
|
|
291
|
+
// contains
|
|
292
|
+
assert.ok(controller.evaluateRules(
|
|
293
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'contains', value: 'world' }], action: { route: 'r' } }],
|
|
294
|
+
{ x: 'hello world' }
|
|
295
|
+
)?.matched, 'contains must match');
|
|
296
|
+
|
|
297
|
+
// matches
|
|
298
|
+
assert.ok(controller.evaluateRules(
|
|
299
|
+
[{ name: 'r', conditions: [{ field: 'x', operator: 'matches', value: '^test-\\d+$' }], action: { route: 'r' } }],
|
|
300
|
+
{ x: 'test-42' }
|
|
301
|
+
)?.matched, 'matches must match');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// 16. evaluateRules — returns null for empty or null input
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
test('evaluateRules returns null for empty or null input', () => {
|
|
309
|
+
const controller = createVirtualModelController();
|
|
310
|
+
|
|
311
|
+
assert.equal(controller.evaluateRules([], {}), null, 'empty rules returns null');
|
|
312
|
+
assert.equal(controller.evaluateRules(null, {}), null, 'null rules returns null');
|
|
313
|
+
assert.equal(controller.evaluateRules([{ name: 'r', conditions: [{ field: 'x', operator: 'eq', value: 1 }], action: { route: 'r' } }], null), null, 'null context returns null');
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// 17. executeHook — executes a JS hook body and returns result
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
test('executeHook executes JS hook body and returns result', () => {
|
|
321
|
+
const controller = createVirtualModelController();
|
|
322
|
+
const result = controller.executeHook('test', 'return args.x + args.y', { x: 2, y: 3 }, {});
|
|
323
|
+
|
|
324
|
+
assert.equal(result, 5, 'must return computed result');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// 18. executeHook — returns null on error
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
test('executeHook returns null on syntax error', () => {
|
|
332
|
+
const controller = createVirtualModelController();
|
|
333
|
+
const result = controller.executeHook('test', 'this is not valid JS {{{', {}, {});
|
|
334
|
+
|
|
335
|
+
assert.equal(result, null, 'must return null on error');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// 19. executeHook — returns null for empty/null hookBody
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
test('executeHook returns null for empty or null hookBody', () => {
|
|
343
|
+
const controller = createVirtualModelController();
|
|
344
|
+
|
|
345
|
+
assert.equal(controller.executeHook('test', null, {}, {}), null, 'null hookBody returns null');
|
|
346
|
+
assert.equal(controller.executeHook('test', '', {}, {}), null, 'empty hookBody returns null');
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// 20. resolveRoute — evaluates rules first
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
test('resolveRoute evaluates rules first (priority 1)', () => {
|
|
354
|
+
const controller = createVirtualModelController();
|
|
355
|
+
const vm = makeVirtualModel('rules-first', {
|
|
356
|
+
rules: [{
|
|
357
|
+
name: 'high-token-rule',
|
|
358
|
+
conditions: [{ field: 'tokenCount', operator: 'gt', value: 1000 }],
|
|
359
|
+
action: { route: 'route-large-model' }
|
|
360
|
+
}]
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
const result = controller.resolveRoute(vm, { tokenCount: 2000 });
|
|
364
|
+
|
|
365
|
+
assert.equal(result.routeRef, 'route-large-model', 'must use rule-matched route');
|
|
366
|
+
assert.equal(result.appliedRule, 'high-token-rule', 'must identify which rule was applied');
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// 21. resolveRoute — executes routeSelect hook (priority 2)
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
|
|
373
|
+
test('resolveRoute executes routeSelect hook when no rules match', () => {
|
|
374
|
+
const controller = createVirtualModelController();
|
|
375
|
+
const vm = makeVirtualModel('hook-select', {
|
|
376
|
+
hooks: {
|
|
377
|
+
routeSelect: 'return "route-hook-selected"'
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const result = controller.resolveRoute(vm, { tokenCount: 100 });
|
|
382
|
+
|
|
383
|
+
assert.equal(result.routeRef, 'route-hook-selected', 'must use hook-selected route');
|
|
384
|
+
assert.equal(result.appliedHook, true, 'must indicate hook was applied');
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// 22. resolveRoute — uses weighted random (priority 3)
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
|
|
391
|
+
test('resolveRoute falls through to weighted random when no rules or hooks match', () => {
|
|
392
|
+
const controller = createVirtualModelController();
|
|
393
|
+
const vm = makeVirtualModel('weighted');
|
|
394
|
+
|
|
395
|
+
const result = controller.resolveRoute(vm, {});
|
|
396
|
+
|
|
397
|
+
assert.ok(result.routeRef, 'must select a route via weighted random');
|
|
398
|
+
assert.ok(['route-primary', 'route-secondary'].includes(result.routeRef), 'must be one of the configured routes');
|
|
399
|
+
assert.equal(result.appliedRule, undefined, 'no rule should be applied');
|
|
400
|
+
assert.equal(result.appliedHook, undefined, 'no hook should be applied');
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// 23. resolveRoute — uses fallbackChain (priority 4)
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
test('resolveRoute uses fallbackChain when routes are empty', () => {
|
|
408
|
+
const controller = createVirtualModelController();
|
|
409
|
+
const vm = makeVirtualModel('fallback', {
|
|
410
|
+
routes: [{ modelRouteRef: '', weight: 100 }],
|
|
411
|
+
fallbackChain: ['route-fallback-1', 'route-fallback-2']
|
|
412
|
+
});
|
|
413
|
+
// Clear the routes so weighted random cannot pick
|
|
414
|
+
vm.spec.routes = [];
|
|
415
|
+
|
|
416
|
+
const result = controller.resolveRoute(vm, {});
|
|
417
|
+
|
|
418
|
+
assert.equal(result.routeRef, 'route-fallback-1', 'must use first fallback');
|
|
419
|
+
assert.equal(result.fallbackUsed, true, 'must indicate fallback was used');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// 24. resolveRoute — returns null when model is disabled
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
test('resolveRoute returns null routeRef when model is disabled', () => {
|
|
427
|
+
const controller = createVirtualModelController();
|
|
428
|
+
const vm = makeVirtualModel('disabled', { enabled: false });
|
|
429
|
+
|
|
430
|
+
const result = controller.resolveRoute(vm, {});
|
|
431
|
+
|
|
432
|
+
assert.equal(result.routeRef, null, 'disabled model must return null routeRef');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// 25. transformRequest — applies requestTransform hook
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
test('transformRequest applies requestTransform hook', () => {
|
|
440
|
+
const controller = createVirtualModelController();
|
|
441
|
+
const vm = makeVirtualModel('transform-req', {
|
|
442
|
+
hooks: {
|
|
443
|
+
requestTransform: 'return { ...args.request, transformed: true }'
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const result = controller.transformRequest(vm, { prompt: 'hello' }, {});
|
|
448
|
+
|
|
449
|
+
assert.equal(result.prompt, 'hello', 'must preserve original fields');
|
|
450
|
+
assert.equal(result.transformed, true, 'must apply transformation');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// ---------------------------------------------------------------------------
|
|
454
|
+
// 26. transformRequest — returns original on no hook
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
|
|
457
|
+
test('transformRequest returns original request when no hook defined', () => {
|
|
458
|
+
const controller = createVirtualModelController();
|
|
459
|
+
const vm = makeVirtualModel('no-transform');
|
|
460
|
+
const request = { prompt: 'hello' };
|
|
461
|
+
|
|
462
|
+
const result = controller.transformRequest(vm, request, {});
|
|
463
|
+
|
|
464
|
+
assert.deepEqual(result, request, 'must return original request');
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// 27. transformResponse — applies responseTransform hook
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
test('transformResponse applies responseTransform hook', () => {
|
|
472
|
+
const controller = createVirtualModelController();
|
|
473
|
+
const vm = makeVirtualModel('transform-resp', {
|
|
474
|
+
hooks: {
|
|
475
|
+
responseTransform: 'return { ...args.response, filtered: true }'
|
|
476
|
+
}
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
const result = controller.transformResponse(vm, { content: 'world' }, {});
|
|
480
|
+
|
|
481
|
+
assert.equal(result.content, 'world', 'must preserve original fields');
|
|
482
|
+
assert.equal(result.filtered, true, 'must apply transformation');
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// 28. handleSessionEvent — terminates on maxTurns exceeded
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
test('handleSessionEvent terminates when maxTurns exceeded', () => {
|
|
490
|
+
const controller = createVirtualModelController();
|
|
491
|
+
const vm = makeVirtualModel('session-max', {
|
|
492
|
+
sessionConfig: { enabled: true, maxTurns: 5 }
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
const result = controller.handleSessionEvent(vm, 'turnEnd', { turnCount: 5 });
|
|
496
|
+
|
|
497
|
+
assert.equal(result.action, 'terminate', 'must terminate when maxTurns exceeded');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
// ---------------------------------------------------------------------------
|
|
501
|
+
// 29. handleSessionEvent — escalates on threshold exceeded
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
test('handleSessionEvent escalates when escalationThreshold exceeded', () => {
|
|
505
|
+
const controller = createVirtualModelController();
|
|
506
|
+
const vm = makeVirtualModel('session-escalate', {
|
|
507
|
+
sessionConfig: { enabled: true, maxTurns: 100, escalationThreshold: 50000 }
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
const result = controller.handleSessionEvent(vm, 'turnEnd', { turnCount: 3, totalTokens: 60000 });
|
|
511
|
+
|
|
512
|
+
assert.equal(result.action, 'escalate', 'must escalate when threshold exceeded');
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
// ---------------------------------------------------------------------------
|
|
516
|
+
// 30. handleSessionEvent — continues normally
|
|
517
|
+
// ---------------------------------------------------------------------------
|
|
518
|
+
|
|
519
|
+
test('handleSessionEvent returns continue for normal session', () => {
|
|
520
|
+
const controller = createVirtualModelController();
|
|
521
|
+
const vm = makeVirtualModel('session-ok', {
|
|
522
|
+
sessionConfig: { enabled: true, maxTurns: 100 }
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
const result = controller.handleSessionEvent(vm, 'turnEnd', { turnCount: 3, totalTokens: 1000 });
|
|
526
|
+
|
|
527
|
+
assert.equal(result.action, 'continue', 'must continue for normal session');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// 31. handleSessionEvent — executes sessionLifecycle hook
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
test('handleSessionEvent executes sessionLifecycle hook', () => {
|
|
535
|
+
const controller = createVirtualModelController();
|
|
536
|
+
const vm = makeVirtualModel('session-hook', {
|
|
537
|
+
sessionConfig: { enabled: true, maxTurns: 100 },
|
|
538
|
+
hooks: {
|
|
539
|
+
sessionLifecycle: 'return { action: "reroute", nextRoute: "route-premium" }'
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
const result = controller.handleSessionEvent(vm, 'escalation', { turnCount: 3, totalTokens: 1000 });
|
|
544
|
+
|
|
545
|
+
assert.equal(result.action, 'reroute', 'must use hook result action');
|
|
546
|
+
assert.equal(result.nextRoute, 'route-premium', 'must use hook result nextRoute');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// 32. emitObservability — emits to event bus
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
|
|
553
|
+
test('emitObservability emits event to event bus', () => {
|
|
554
|
+
const bus = createEventBus();
|
|
555
|
+
const controller = createVirtualModelController({ eventBus: bus });
|
|
556
|
+
const vm = makeVirtualModel('observe');
|
|
557
|
+
|
|
558
|
+
let emitted = null;
|
|
559
|
+
bus.subscribe(event => { emitted = event; });
|
|
560
|
+
|
|
561
|
+
controller.emitObservability(vm, 'request-completed', { latencyMs: 150, tokenCount: 500 });
|
|
562
|
+
|
|
563
|
+
assert.ok(emitted, 'must emit an event');
|
|
564
|
+
assert.equal(emitted.type, 'virtual-model-observability', 'event type must match');
|
|
565
|
+
assert.equal(emitted.kind, 'KradleVirtualModel', 'kind must match');
|
|
566
|
+
assert.equal(emitted.modelName, 'model-observe', 'modelName must match');
|
|
567
|
+
assert.equal(emitted.event, 'request-completed', 'event name must match');
|
|
568
|
+
assert.equal(emitted.metrics.latencyMs, 150, 'metrics must be included');
|
|
569
|
+
assert.ok(emitted.timestamp, 'timestamp must be present');
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// ---------------------------------------------------------------------------
|
|
573
|
+
// 33. reconcileVirtualModels — marks valid models as ready
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
|
|
576
|
+
test('reconcileVirtualModels marks valid models as VirtualModelReady True', () => {
|
|
577
|
+
const controller = createVirtualModelController();
|
|
578
|
+
const vm = makeVirtualModel('ready');
|
|
579
|
+
const routes = [makeModelRoute('route-primary'), makeModelRoute('route-secondary')];
|
|
580
|
+
|
|
581
|
+
const { conditions, resolvedModels } = controller.reconcileVirtualModels([vm], routes);
|
|
582
|
+
|
|
583
|
+
assert.equal(conditions.length, 1, 'must have one condition');
|
|
584
|
+
assert.equal(conditions[0].status, 'True', 'status must be True');
|
|
585
|
+
assert.equal(conditions[0].reason, 'Reconciled', 'reason must be Reconciled');
|
|
586
|
+
assert.equal(resolvedModels.length, 1, 'must have one resolved model');
|
|
587
|
+
assert.equal(resolvedModels[0].routeCount, 2, 'must have 2 routes');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
// ---------------------------------------------------------------------------
|
|
591
|
+
// 34. reconcileVirtualModels — marks invalid models as not ready
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
test('reconcileVirtualModels marks invalid models as VirtualModelReady False', () => {
|
|
595
|
+
const controller = createVirtualModelController();
|
|
596
|
+
const vm = makeVirtualModel('invalid');
|
|
597
|
+
delete vm.spec.modelName;
|
|
598
|
+
|
|
599
|
+
const { conditions, resolvedModels } = controller.reconcileVirtualModels([vm], []);
|
|
600
|
+
|
|
601
|
+
assert.equal(conditions.length, 1, 'must have one condition');
|
|
602
|
+
assert.equal(conditions[0].status, 'False', 'status must be False');
|
|
603
|
+
assert.equal(conditions[0].reason, 'ValidationFailed', 'reason must be ValidationFailed');
|
|
604
|
+
assert.equal(resolvedModels.length, 0, 'must have no resolved models');
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
// 35. KradleVirtualModel can be created via createResource
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
|
|
611
|
+
test('KradleVirtualModel can be created via createResource', () => {
|
|
612
|
+
const resource = createResource('KradleVirtualModel', { name: 'test-vm', namespace: 'kradle-org-default' }, {
|
|
613
|
+
organizationRef: 'default',
|
|
614
|
+
modelName: 'gpt-4o-virtual',
|
|
615
|
+
routes: [{ modelRouteRef: 'route-a', weight: 100 }]
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
assert.equal(resource.kind, 'KradleVirtualModel', 'kind must match');
|
|
619
|
+
assert.equal(resource.apiVersion, 'kradle.a5c.ai/v1alpha1', 'apiVersion must match');
|
|
620
|
+
assert.equal(resource.spec.modelName, 'gpt-4o-virtual', 'modelName must be set');
|
|
621
|
+
assert.ok(Array.isArray(resource.spec.routes), 'routes must be an array');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// ---------------------------------------------------------------------------
|
|
625
|
+
// 36. validateVirtualModel standalone export works
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
test('validateVirtualModel standalone export works', () => {
|
|
629
|
+
assert.equal(typeof validateVirtualModel, 'function', 'must be a named export');
|
|
630
|
+
const vm = makeVirtualModel('standalone');
|
|
631
|
+
const result = validateVirtualModel(vm);
|
|
632
|
+
assert.equal(result.valid, true, 'valid vm must pass standalone validation');
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// ---------------------------------------------------------------------------
|
|
636
|
+
// 37. validate accumulates multiple errors
|
|
637
|
+
// ---------------------------------------------------------------------------
|
|
638
|
+
|
|
639
|
+
test('validate accumulates multiple errors at once', () => {
|
|
640
|
+
const controller = createVirtualModelController();
|
|
641
|
+
const vm = {
|
|
642
|
+
apiVersion: 'kradle.a5c.ai/v1alpha1',
|
|
643
|
+
kind: 'KradleVirtualModel',
|
|
644
|
+
metadata: { name: 'multi-error', namespace: 'default', labels: {}, annotations: {} },
|
|
645
|
+
spec: {},
|
|
646
|
+
status: {}
|
|
647
|
+
};
|
|
648
|
+
const result = controller.validate(vm);
|
|
649
|
+
|
|
650
|
+
assert.equal(result.valid, false, 'must fail validation');
|
|
651
|
+
assert.ok(result.errors.length >= 3, 'must have at least 3 errors (orgRef, modelName, routes)');
|
|
652
|
+
assert.ok(result.errors.some(e => /organizationRef/i.test(e)), 'must mention organizationRef');
|
|
653
|
+
assert.ok(result.errors.some(e => /modelName/i.test(e)), 'must mention modelName');
|
|
654
|
+
assert.ok(result.errors.some(e => /routes/i.test(e)), 'must mention routes');
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// 38. evaluateRules — multiple conditions must all match (AND logic)
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
test('evaluateRules requires all conditions to match (AND logic)', () => {
|
|
662
|
+
const controller = createVirtualModelController();
|
|
663
|
+
const rules = [{
|
|
664
|
+
name: 'multi-condition',
|
|
665
|
+
conditions: [
|
|
666
|
+
{ field: 'userRole', operator: 'eq', value: 'premium' },
|
|
667
|
+
{ field: 'tokenCount', operator: 'gt', value: 1000 }
|
|
668
|
+
],
|
|
669
|
+
action: { route: 'route-premium-large' }
|
|
670
|
+
}];
|
|
671
|
+
|
|
672
|
+
// Both match
|
|
673
|
+
const match = controller.evaluateRules(rules, { userRole: 'premium', tokenCount: 2000 });
|
|
674
|
+
assert.ok(match?.matched, 'both conditions met must match');
|
|
675
|
+
|
|
676
|
+
// Only one matches
|
|
677
|
+
const noMatch = controller.evaluateRules(rules, { userRole: 'premium', tokenCount: 500 });
|
|
678
|
+
assert.equal(noMatch, null, 'partial conditions must not match');
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// 39. transformResponse — returns original on hook error
|
|
683
|
+
// ---------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
test('transformResponse returns original response on hook error', () => {
|
|
686
|
+
const controller = createVirtualModelController();
|
|
687
|
+
const vm = makeVirtualModel('bad-transform', {
|
|
688
|
+
hooks: { responseTransform: 'throw new Error("boom")' }
|
|
689
|
+
});
|
|
690
|
+
const response = { content: 'original' };
|
|
691
|
+
|
|
692
|
+
const result = controller.transformResponse(vm, response, {});
|
|
693
|
+
|
|
694
|
+
assert.deepEqual(result, response, 'must return original response on error');
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
// 40. resolveRoute — returns null when no routes, rules, hooks, or fallback
|
|
699
|
+
// ---------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
test('resolveRoute returns null routeRef when all resolution paths exhausted', () => {
|
|
702
|
+
const controller = createVirtualModelController();
|
|
703
|
+
const vm = makeVirtualModel('exhausted');
|
|
704
|
+
vm.spec.routes = [];
|
|
705
|
+
delete vm.spec.fallbackChain;
|
|
706
|
+
|
|
707
|
+
const result = controller.resolveRoute(vm, {});
|
|
708
|
+
|
|
709
|
+
assert.equal(result.routeRef, null, 'must return null when all paths exhausted');
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ---------------------------------------------------------------------------
|
|
713
|
+
// 41. validate — accepts valid sessionConfig
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
|
|
716
|
+
test('validate accepts valid sessionConfig', () => {
|
|
717
|
+
const controller = createVirtualModelController();
|
|
718
|
+
const vm = makeVirtualModel('session-valid', {
|
|
719
|
+
sessionConfig: { enabled: true, maxTurns: 10, escalationThreshold: 50000, stateFields: ['topic'] }
|
|
720
|
+
});
|
|
721
|
+
const result = controller.validate(vm);
|
|
722
|
+
|
|
723
|
+
assert.equal(result.valid, true, 'valid sessionConfig must pass');
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
// ---------------------------------------------------------------------------
|
|
727
|
+
// 42. validate — rejects invalid sessionConfig.maxTurns
|
|
728
|
+
// ---------------------------------------------------------------------------
|
|
729
|
+
|
|
730
|
+
test('validate rejects invalid sessionConfig.maxTurns', () => {
|
|
731
|
+
const controller = createVirtualModelController();
|
|
732
|
+
const vm = makeVirtualModel('session-bad', {
|
|
733
|
+
sessionConfig: { enabled: true, maxTurns: -1 }
|
|
734
|
+
});
|
|
735
|
+
const result = controller.validate(vm);
|
|
736
|
+
|
|
737
|
+
assert.equal(result.valid, false, 'negative maxTurns must fail');
|
|
738
|
+
assert.ok(result.errors.some(e => /maxTurns/i.test(e)), 'error must mention maxTurns');
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
// ── Agentic Lifecycle Hook Tests ──────────────────────────────────────────
|
|
742
|
+
|
|
743
|
+
test('fireSessionStart emits event and runs hook', () => {
|
|
744
|
+
const events = [];
|
|
745
|
+
const bus = createEventBus();
|
|
746
|
+
bus.subscribe((e) => events.push(e));
|
|
747
|
+
const controller = createVirtualModelController({ eventBus: bus });
|
|
748
|
+
const vm = makeVirtualModel('sess-start', { hooks: { onSessionStart: 'return undefined;' } });
|
|
749
|
+
controller.fireSessionStart(vm, { id: 's1' });
|
|
750
|
+
assert.ok(events.some(e => e.type === 'virtual-model-session-start'));
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test('fireSessionEnd emits event with turn count', () => {
|
|
754
|
+
const events = [];
|
|
755
|
+
const bus = createEventBus();
|
|
756
|
+
bus.subscribe((e) => events.push(e));
|
|
757
|
+
const controller = createVirtualModelController({ eventBus: bus });
|
|
758
|
+
const vm = makeVirtualModel('sess-end');
|
|
759
|
+
controller.fireSessionEnd(vm, { id: 's1', turnCount: 5 });
|
|
760
|
+
const ev = events.find(e => e.type === 'virtual-model-session-end');
|
|
761
|
+
assert.ok(ev);
|
|
762
|
+
assert.equal(ev.turns, 5);
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
test('fireTurnEnd returns action from hook', () => {
|
|
766
|
+
const controller = createVirtualModelController();
|
|
767
|
+
const vm = makeVirtualModel('turn-end', { hooks: { onTurnEnd: 'return { action: "escalate" };' } });
|
|
768
|
+
const result = controller.fireTurnEnd(vm, { turnNumber: 3 }, { id: 's1' });
|
|
769
|
+
assert.equal(result.action, 'escalate');
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
test('fireTurnEnd returns continue when no hook', () => {
|
|
773
|
+
const controller = createVirtualModelController();
|
|
774
|
+
const vm = makeVirtualModel('turn-end-noop');
|
|
775
|
+
const result = controller.fireTurnEnd(vm, { turnNumber: 1 }, { id: 's1' });
|
|
776
|
+
assert.equal(result.action, 'continue');
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test('firePreToolUse blocks tool via hook', () => {
|
|
780
|
+
const controller = createVirtualModelController();
|
|
781
|
+
const vm = makeVirtualModel('block-tool', { hooks: { onPreToolUse: 'if (args.toolCall.name === "rm") return { allow: false }; return { allow: true };' } });
|
|
782
|
+
const blocked = controller.firePreToolUse(vm, { name: 'rm', args: {} }, { id: 's1' });
|
|
783
|
+
assert.equal(blocked.allow, false);
|
|
784
|
+
const allowed = controller.firePreToolUse(vm, { name: 'read', args: {} }, { id: 's1' });
|
|
785
|
+
assert.equal(allowed.allow, true);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
test('firePreToolUse allows by default when no hook', () => {
|
|
789
|
+
const controller = createVirtualModelController();
|
|
790
|
+
const vm = makeVirtualModel('no-pretool');
|
|
791
|
+
const result = controller.firePreToolUse(vm, { name: 'write' }, { id: 's1' });
|
|
792
|
+
assert.equal(result.allow, true);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test('firePostToolUse modifies result via hook', () => {
|
|
796
|
+
const controller = createVirtualModelController();
|
|
797
|
+
const vm = makeVirtualModel('post-tool', { hooks: { onPostToolUse: 'return { modified: "redacted" };' } });
|
|
798
|
+
const result = controller.firePostToolUse(vm, { name: 'read' }, 'secret-data', { id: 's1' });
|
|
799
|
+
assert.equal(result.modified, 'redacted');
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
test('fireUserPromptSubmit blocks prompt via hook', () => {
|
|
803
|
+
const controller = createVirtualModelController();
|
|
804
|
+
const vm = makeVirtualModel('block-prompt', { hooks: { onUserPromptSubmit: 'if (args.prompt.includes("hack")) return { block: true }; return { block: false };' } });
|
|
805
|
+
const blocked = controller.fireUserPromptSubmit(vm, 'hack the system', { id: 's1' });
|
|
806
|
+
assert.equal(blocked.block, true);
|
|
807
|
+
const allowed = controller.fireUserPromptSubmit(vm, 'hello', { id: 's1' });
|
|
808
|
+
assert.equal(allowed.block, false);
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
test('fireError returns retry with fallback route', () => {
|
|
812
|
+
const controller = createVirtualModelController();
|
|
813
|
+
const vm = makeVirtualModel('error-retry', { hooks: { onError: 'return { retry: true, fallbackRoute: "route-backup" };' } });
|
|
814
|
+
const result = controller.fireError(vm, { message: 'timeout' }, { id: 's1' });
|
|
815
|
+
assert.equal(result.retry, true);
|
|
816
|
+
assert.equal(result.fallbackRoute, 'route-backup');
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test('fireError returns no retry by default', () => {
|
|
820
|
+
const controller = createVirtualModelController();
|
|
821
|
+
const vm = makeVirtualModel('error-default');
|
|
822
|
+
const result = controller.fireError(vm, { message: 'fail' }, { id: 's1' });
|
|
823
|
+
assert.equal(result.retry, false);
|
|
824
|
+
assert.equal(result.fallbackRoute, null);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test('fireCompact modifies summary via hook', () => {
|
|
828
|
+
const controller = createVirtualModelController();
|
|
829
|
+
const vm = makeVirtualModel('compact', { hooks: { onCompact: 'return { modified: args.summary + " [redacted]" };' } });
|
|
830
|
+
const result = controller.fireCompact(vm, 'conversation summary', { id: 's1' });
|
|
831
|
+
assert.equal(result.modified, 'conversation summary [redacted]');
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
test('fireCompact returns null modified when no hook', () => {
|
|
835
|
+
const controller = createVirtualModelController();
|
|
836
|
+
const vm = makeVirtualModel('compact-noop');
|
|
837
|
+
const result = controller.fireCompact(vm, 'summary', { id: 's1' });
|
|
838
|
+
assert.equal(result.modified, null);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// ── Hook Sandbox Adversarial Tests ────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
test('sandbox blocks access to process', () => {
|
|
844
|
+
const controller = createVirtualModelController();
|
|
845
|
+
const result = controller.executeHook('test', 'return typeof process', {}, {});
|
|
846
|
+
assert.ok(result === 'undefined' || result === undefined || result === null, 'process must not be accessible');
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test('sandbox blocks access to require', () => {
|
|
850
|
+
const controller = createVirtualModelController();
|
|
851
|
+
const result = controller.executeHook('test', 'return typeof require', {}, {});
|
|
852
|
+
assert.ok(result === 'undefined' || result === undefined || result === null, 'require must not be accessible');
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test('sandbox blocks access to globalThis properties', () => {
|
|
856
|
+
const controller = createVirtualModelController();
|
|
857
|
+
const result = controller.executeHook('test', 'try { return typeof globalThis.process } catch { return "blocked" }', {}, {});
|
|
858
|
+
assert.ok(result === 'undefined' || result === undefined || result === null || result === 'blocked', 'globalThis.process must not be accessible');
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
test('sandbox prevents prototype pollution', () => {
|
|
862
|
+
const controller = createVirtualModelController();
|
|
863
|
+
const result = controller.executeHook('test', 'try { args.__proto__.polluted = true; return args.polluted } catch { return null }', { clean: true }, {});
|
|
864
|
+
assert.ok(result === undefined || result === null, 'prototype pollution must be blocked (args are frozen)');
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
test('sandbox enforces timeout on infinite loops', () => {
|
|
868
|
+
const controller = createVirtualModelController();
|
|
869
|
+
const result = controller.executeHook('test', 'while(true){}', {}, {});
|
|
870
|
+
assert.equal(result, null, 'infinite loop must timeout and return null');
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
test('sandbox blocks constructor escape', () => {
|
|
874
|
+
const controller = createVirtualModelController();
|
|
875
|
+
const result = controller.executeHook('test', 'try { return this.constructor.constructor("return process")() } catch { return null }', {}, {});
|
|
876
|
+
assert.equal(result, null, 'constructor escape must be blocked');
|
|
877
|
+
});
|