Skip to main content

weft_core/api/
scenes.rs

1use crate::api::openai_compat::AppState;
2use axum::extract::{Path, State};
3use axum::http::StatusCode;
4use axum::Json;
5use serde::Deserialize;
6
7#[derive(Debug, Deserialize)]
8pub struct CreateSceneRequest {
9    pub name: String,
10    #[serde(flatten)]
11    pub scene: crate::app::AppSceneConfig,
12}
13
14#[derive(Debug, Deserialize)]
15pub struct BindSceneRequest {
16    #[serde(default)]
17    pub scene: String,
18}
19
20fn app_not_found(name: &str) -> (StatusCode, Json<serde_json::Value>) {
21    (
22        StatusCode::NOT_FOUND,
23        Json(serde_json::json!({
24            "error": format!("App '{}' not found", name),
25            "reason": "app_not_found",
26            "app": name,
27        })),
28    )
29}
30
31fn scene_not_found(app_name: &str, scene_name: &str) -> (StatusCode, Json<serde_json::Value>) {
32    (
33        StatusCode::NOT_FOUND,
34        Json(serde_json::json!({
35            "error": format!("Scene '{}' not found for app '{}'", scene_name, app_name),
36            "reason": "scene_not_found",
37            "app": app_name,
38            "scene": scene_name,
39        })),
40    )
41}
42
43fn bad_scene_request(reason: &str, message: &str) -> (StatusCode, Json<serde_json::Value>) {
44    (
45        StatusCode::BAD_REQUEST,
46        Json(serde_json::json!({
47            "error": message,
48            "reason": reason,
49        })),
50    )
51}
52
53fn scene_conflict(app_name: &str, scene_name: &str) -> (StatusCode, Json<serde_json::Value>) {
54    (
55        StatusCode::CONFLICT,
56        Json(serde_json::json!({
57            "error": format!("Scene '{}' already exists for app '{}'", scene_name, app_name),
58            "reason": "scene_exists",
59            "app": app_name,
60            "scene": scene_name,
61        })),
62    )
63}
64
65fn scene_write_failed(error: anyhow::Error) -> (StatusCode, Json<serde_json::Value>) {
66    (
67        StatusCode::INTERNAL_SERVER_ERROR,
68        Json(serde_json::json!({
69            "error": error.to_string(),
70            "reason": "scene_write_failed",
71        })),
72    )
73}
74
75fn load_instance_config(app: &crate::app::ResolvedApp) -> crate::app::InstanceConfig {
76    app.sources
77        .config_path
78        .as_deref()
79        .map(std::path::Path::new)
80        .and_then(|path| crate::app::load_instance_config_from_path(path).ok())
81        .unwrap_or_default()
82}
83
84fn instance_config_path(
85    app: &crate::app::ResolvedApp,
86) -> Result<std::path::PathBuf, (StatusCode, Json<serde_json::Value>)> {
87    app.sources
88        .config_path
89        .as_deref()
90        .or(app.config_path.as_deref())
91        .filter(|path| !path.trim().is_empty())
92        .map(std::path::PathBuf::from)
93        .ok_or_else(|| {
94            bad_scene_request(
95                "config_path_missing",
96                "App has no writable instance config path",
97            )
98        })
99}
100
101fn scene_file_name(scene_name: &str) -> Result<String, (StatusCode, Json<serde_json::Value>)> {
102    let trimmed = scene_name.trim();
103    if trimmed.is_empty() {
104        return Err(bad_scene_request(
105            "scene_name_empty",
106            "Scene name is required",
107        ));
108    }
109
110    if trimmed.contains('/') || trimmed.contains('\\') || trimmed == "." || trimmed == ".." {
111        return Err(bad_scene_request(
112            "scene_name_invalid",
113            "Scene name must be a simple file name",
114        ));
115    }
116
117    Ok(trimmed.to_string())
118}
119
120fn scene_response(
121    app_name: String,
122    active_scene: String,
123    scene: crate::app::AppSceneConfig,
124) -> Json<serde_json::Value> {
125    Json(serde_json::json!({
126        "app": app_name,
127        "active_scene": active_scene,
128        "scene": scene,
129    }))
130}
131
132pub async fn list_scenes(
133    Path(name): Path<String>,
134    State(state): State<AppState>,
135) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
136    let apps = state.resolved_apps.read().await;
137    let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
138    let config = load_instance_config(app);
139
140    let mut scenes = config
141        .scenes
142        .into_values()
143        .collect::<Vec<crate::app::AppSceneConfig>>();
144    scenes.sort_by(|left, right| left.name.cmp(&right.name));
145
146    Ok(Json(serde_json::json!({
147        "app": name,
148        "active_scene": config.active_scene,
149        "scenes": scenes,
150    })))
151}
152
153pub async fn get_scene(
154    Path((name, scene_name)): Path<(String, String)>,
155    State(state): State<AppState>,
156) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
157    let apps = state.resolved_apps.read().await;
158    let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
159    let config = load_instance_config(app);
160    let scene = config
161        .scenes
162        .get(&scene_name)
163        .cloned()
164        .ok_or_else(|| scene_not_found(&name, &scene_name))?;
165
166    Ok(Json(serde_json::json!({
167        "app": name,
168        "active_scene": config.active_scene,
169        "scene": scene,
170    })))
171}
172
173pub async fn create_scene(
174    Path(name): Path<String>,
175    State(state): State<AppState>,
176    Json(mut request): Json<CreateSceneRequest>,
177) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
178    let apps = state.resolved_apps.read().await;
179    let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
180    let config_path = instance_config_path(app)?;
181    let config = load_instance_config(app);
182    let scene_name = scene_file_name(&request.name)?;
183
184    if config.scenes.contains_key(&scene_name) {
185        return Err(scene_conflict(&name, &scene_name));
186    }
187
188    request.scene.name = scene_name.clone();
189    if request.scene.schema_version == 0 {
190        request.scene.schema_version = 1;
191    }
192
193    let scene_path = config_path
194        .parent()
195        .unwrap_or_else(|| std::path::Path::new("."))
196        .join("scenes")
197        .join(format!("{}.toml", scene_name));
198    crate::app::save_scene_config_to_path(&scene_path, &request.scene)
199        .map_err(scene_write_failed)?;
200
201    Ok(scene_response(name, config.active_scene, request.scene))
202}
203
204pub async fn bind_scene(
205    Path((name, scene_name)): Path<(String, String)>,
206    State(state): State<AppState>,
207    Json(request): Json<BindSceneRequest>,
208) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
209    let apps = state.resolved_apps.read().await;
210    let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
211    let config_path = instance_config_path(app)?;
212    let mut config = load_instance_config(app);
213    let requested_scene = if request.scene.trim().is_empty() {
214        scene_name
215    } else {
216        request.scene
217    };
218    let scene_name = scene_file_name(&requested_scene)?;
219    let scene = config
220        .scenes
221        .get(&scene_name)
222        .cloned()
223        .ok_or_else(|| scene_not_found(&name, &scene_name))?;
224
225    config.active_scene = scene_name.clone();
226    crate::app::save_instance_config_to_path(&config_path, &config).map_err(scene_write_failed)?;
227
228    // 联动应用 scene 的运行时偏好:把 role_routing 写入 KV `team:role_routing`,
229    // 使切场景即切团队各角色用的模型(team-runtime 下次按角色读取)。
230    // 仅当 scene 显式配置了 role_routing 时才覆盖,空则保持现状(不清空已有路由)。
231    if !scene.role_routing.is_empty() {
232        if let Ok(role_routing_json) = serde_json::to_string(&scene.role_routing) {
233            if let Some(handle) = state.wasm_handle.read().await.as_ref() {
234                handle.kv_set("team:role_routing", &role_routing_json);
235                tracing::info!(
236                    "scene '{}' bound: applied {} role(s) to team:role_routing",
237                    scene_name,
238                    scene.role_routing.len()
239                );
240            }
241        }
242    }
243
244    Ok(scene_response(name, scene_name, scene))
245}
246
247pub async fn delete_scene(
248    Path((name, scene_name)): Path<(String, String)>,
249    State(state): State<AppState>,
250) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
251    let apps = state.resolved_apps.read().await;
252    let app = apps.get(&name).ok_or_else(|| app_not_found(&name))?;
253    let config_path = instance_config_path(app)?;
254    let config = load_instance_config(app);
255    let scene_name = scene_file_name(&scene_name)?;
256
257    if !config.scenes.contains_key(&scene_name) {
258        return Err(scene_not_found(&name, &scene_name));
259    }
260    // 不允许删除当前激活场景,避免 active_scene 指向不存在的场景(否则后续
261    // propose_generation 会拿不到场景配置)。先 bind 到别的场景再删。
262    if config.active_scene == scene_name {
263        return Err((
264            StatusCode::CONFLICT,
265            Json(serde_json::json!({
266                "error": format!("Cannot delete active scene '{}'", scene_name),
267                "reason": "scene_active",
268                "app": name,
269                "scene": scene_name,
270            })),
271        ));
272    }
273
274    let scene_path = config_path
275        .parent()
276        .unwrap_or_else(|| std::path::Path::new("."))
277        .join("scenes")
278        .join(format!("{}.toml", scene_name));
279    if scene_path.exists() {
280        std::fs::remove_file(&scene_path)
281            .map_err(|e| scene_write_failed(anyhow::Error::from(e)))?;
282    }
283
284    Ok(Json(serde_json::json!({
285        "app": name,
286        "deleted": scene_name,
287        "active_scene": config.active_scene,
288    })))
289}
290
291#[cfg(test)]
292mod tests {
293    use crate::api::build_router;
294    use crate::api::openai_compat::AppState;
295    use crate::app::{
296        AppProfile, CapabilityRegistry, CorePolicy, GenerationStoreMap, PackageIndex,
297        PackageSource, ResolvedApp, ResolvedAppMap, ResolvedAppSources, ResolvedAppStatus,
298    };
299    use crate::config::{
300        AppConfig, CoreConfig, FallbackConfig, KeyStrategyConfig, RegistryConfig, RoutingConfig,
301    };
302    use crate::defaults::{
303        error_handler::DefaultErrorHandler, key_selectors::FailoverSelector, router::DefaultRouter,
304    };
305    use crate::pipeline::Pipeline;
306    use crate::process::ProcessManager;
307    use crate::vkeys::VirtualKeyStore;
308    use axum::body::Body;
309    use axum::http::{Request, StatusCode as HttpStatusCode};
310    use serde_json::Value;
311    use std::collections::HashMap;
312    use std::sync::{Arc, Mutex as StdMutex};
313    use tempfile::tempdir;
314    use tokio::sync::RwLock;
315    use tower::util::ServiceExt;
316
317    fn test_state(repo_root: std::path::PathBuf, apps: ResolvedAppMap) -> AppState {
318        AppState {
319            config: Arc::new(RwLock::new(AppConfig {
320                core: CoreConfig::default(),
321                providers: vec![],
322                routing: RoutingConfig::default(),
323                key_strategy: KeyStrategyConfig::default(),
324                fallback: FallbackConfig::default(),
325                virtual_keys: vec![],
326                services: vec![],
327                packages: vec![],
328                registry: RegistryConfig::default(),
329                package_aliases: HashMap::new(),
330                web_search: Default::default(),
331                team: Default::default(),
332            })),
333            config_path: repo_root.join("config").join("config.toml"),
334            pipeline: Arc::new(Pipeline {
335                router: Arc::new(DefaultRouter {
336                    default_provider: "".into(),
337                }),
338                key_selector: Arc::new(FailoverSelector),
339                transforms: Arc::new(crate::defaults::transforms::TransformRegistry::with_defaults()),
340                error_handler: Arc::new(DefaultErrorHandler { max_retries: 0 }),
341                http_client: reqwest::Client::new(),
342            }),
343            process_manager: Arc::new(ProcessManager::new()),
344            vkey_store: Arc::new(VirtualKeyStore::new()),
345            package_manager: Arc::new(RwLock::new(crate::package::PackageManager::new())),
346            wasm_handle: Arc::new(RwLock::new(None)),
347            native_handle: Arc::new(RwLock::new(None)),
348            resolved_apps: Arc::new(RwLock::new(apps)),
349            capability_registry: Arc::new(RwLock::new(CapabilityRegistry::new())),
350            active_profile: Arc::new(RwLock::new(AppProfile::Developer)),
351            core_policy: Arc::new(CorePolicy::default_policy()),
352            generation_store: Arc::new(RwLock::new(GenerationStoreMap::new())),
353            package_index: Arc::new(PackageIndex {
354                version: 1,
355                revision: "test-rev".into(),
356                source_url: "local://packages".into(),
357                package_sources: vec![PackageSource {
358                    name: "weft-claw-ui".into(),
359                    kind: "embedded".into(),
360                    package_kind: "provider".into(),
361                    runtime_provider: "weft-claw-ui".into(),
362                    current_source: "packages/installed/weft-claw".into(),
363                    trusted: true,
364                    signature: "builtin:test".into(),
365                    source_authority: "test".into(),
366                    source_public_keys: vec![],
367                    provides: vec![],
368                    requires: vec![],
369                }],
370            }),
371            repo_root,
372            data_dir: std::path::PathBuf::from("data"),
373            runtime_token: None,
374            runtime_token_path: None,
375            chat_providers: Arc::new(RwLock::new(vec![])),
376            shutdown_tx: Arc::new(StdMutex::new(None)),
377            stream_buffer: Arc::new(StdMutex::new(std::collections::HashMap::new())),
378        }
379    }
380
381    fn resolved_app(name: &str, config_path: Option<&std::path::Path>) -> ResolvedApp {
382        ResolvedApp {
383            name: name.into(),
384            version: "0.1.0".into(),
385            display_name: name.into(),
386            description: "test app".into(),
387            capabilities: vec![],
388            enabled_features: vec![],
389            bindings: vec![],
390            validation_checks: vec![],
391            config_path: config_path.map(|path| path.display().to_string()),
392            status: ResolvedAppStatus::Resolved,
393            errors: vec![],
394            sources: ResolvedAppSources {
395                manifest_path: String::new(),
396                config_path: config_path.map(|path| path.display().to_string()),
397                lock_path: None,
398            },
399        }
400    }
401
402    #[tokio::test]
403    async fn list_scenes_returns_merged_scene_files() {
404        let root = tempdir().expect("temp dir");
405        let repo_root = root.path().to_path_buf();
406        let instance_dir = repo_root.join(".weft").join("weft-claw");
407        let scenes_dir = instance_dir.join("scenes");
408        std::fs::create_dir_all(&scenes_dir).expect("scene dir created");
409        std::fs::write(
410            instance_dir.join("config.toml"),
411            r#"schema_version = 1
412active_scene = "team"
413
414[scenes.stable]
415name = "stable"
416description = "Stable embedded scene"
417"#,
418        )
419        .expect("config written");
420        std::fs::write(
421            scenes_dir.join("team.toml"),
422            r#"schema_version = 1
423description = "Team scene from file"
424profile = "developer"
425
426[features]
427enabled = ["team-mode"]
428disabled = ["legacy-mode"]
429"#,
430        )
431        .expect("scene file written");
432
433        let mut apps = ResolvedAppMap::new();
434        apps.insert(
435            "weft-claw".into(),
436            resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
437        );
438
439        let response = build_router(test_state(repo_root, apps))
440            .oneshot(
441                Request::builder()
442                    .uri("/api/apps/weft-claw/scenes")
443                    .body(Body::empty())
444                    .unwrap(),
445            )
446            .await
447            .unwrap();
448
449        assert_eq!(response.status(), HttpStatusCode::OK);
450        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
451            .await
452            .unwrap();
453        let payload: Value = serde_json::from_slice(&body).unwrap();
454        assert_eq!(payload["app"], "weft-claw");
455        assert_eq!(payload["active_scene"], "team");
456        let scenes = payload["scenes"].as_array().expect("scenes array");
457        assert_eq!(scenes.len(), 2);
458        assert_eq!(scenes[0]["name"], "stable");
459        assert_eq!(scenes[1]["name"], "team");
460        assert_eq!(scenes[1]["description"], "Team scene from file");
461        assert_eq!(scenes[1]["profile"], "developer");
462        assert_eq!(
463            scenes[1]["features"]["enabled"],
464            serde_json::json!(["team-mode"])
465        );
466        assert_eq!(
467            scenes[1]["features"]["disabled"],
468            serde_json::json!(["legacy-mode"])
469        );
470    }
471
472    #[tokio::test]
473    async fn get_scene_returns_scene_from_config() {
474        let root = tempdir().expect("temp dir");
475        let repo_root = root.path().to_path_buf();
476        let instance_dir = repo_root.join(".weft").join("weft-claw");
477        std::fs::create_dir_all(&instance_dir).expect("instance dir created");
478        std::fs::write(
479            instance_dir.join("config.toml"),
480            r#"schema_version = 1
481active_scene = "team"
482
483[scenes.team]
484name = "team"
485description = "Team scene"
486profile = "developer"
487base_generation = 7
488"#,
489        )
490        .expect("config written");
491
492        let mut apps = ResolvedAppMap::new();
493        apps.insert(
494            "weft-claw".into(),
495            resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
496        );
497
498        let response = build_router(test_state(repo_root, apps))
499            .oneshot(
500                Request::builder()
501                    .uri("/api/apps/weft-claw/scenes/team")
502                    .body(Body::empty())
503                    .unwrap(),
504            )
505            .await
506            .unwrap();
507
508        assert_eq!(response.status(), HttpStatusCode::OK);
509        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
510            .await
511            .unwrap();
512        let payload: Value = serde_json::from_slice(&body).unwrap();
513        assert_eq!(payload["app"], "weft-claw");
514        assert_eq!(payload["active_scene"], "team");
515        assert_eq!(payload["scene"]["name"], "team");
516        assert_eq!(payload["scene"]["description"], "Team scene");
517        assert_eq!(payload["scene"]["base_generation"], 7);
518    }
519
520    #[tokio::test]
521    async fn get_scene_returns_structured_not_found_for_missing_scene() {
522        let root = tempdir().expect("temp dir");
523        let repo_root = root.path().to_path_buf();
524        let instance_dir = repo_root.join(".weft").join("weft-claw");
525        std::fs::create_dir_all(&instance_dir).expect("instance dir created");
526        std::fs::write(instance_dir.join("config.toml"), "schema_version = 1\n")
527            .expect("config written");
528
529        let mut apps = ResolvedAppMap::new();
530        apps.insert(
531            "weft-claw".into(),
532            resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
533        );
534
535        let response = build_router(test_state(repo_root, apps))
536            .oneshot(
537                Request::builder()
538                    .uri("/api/apps/weft-claw/scenes/missing")
539                    .body(Body::empty())
540                    .unwrap(),
541            )
542            .await
543            .unwrap();
544
545        assert_eq!(response.status(), HttpStatusCode::NOT_FOUND);
546        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
547            .await
548            .unwrap();
549        let payload: Value = serde_json::from_slice(&body).unwrap();
550        assert_eq!(payload["reason"], "scene_not_found");
551        assert_eq!(payload["app"], "weft-claw");
552        assert_eq!(payload["scene"], "missing");
553    }
554
555    #[tokio::test]
556    async fn create_scene_writes_scene_file() {
557        let root = tempdir().expect("temp dir");
558        let repo_root = root.path().to_path_buf();
559        let instance_dir = repo_root.join(".weft").join("weft-claw");
560        std::fs::create_dir_all(&instance_dir).expect("instance dir created");
561        std::fs::write(instance_dir.join("config.toml"), "schema_version = 1\n")
562            .expect("config written");
563
564        let mut apps = ResolvedAppMap::new();
565        apps.insert(
566            "weft-claw".into(),
567            resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
568        );
569
570        let response = build_router(test_state(repo_root, apps))
571            .oneshot(
572                Request::builder()
573                    .method("POST")
574                    .uri("/api/apps/weft-claw/scenes")
575                    .header("content-type", "application/json")
576                    .body(Body::from(
577                        r#"{"name":"team","description":"Team scene","profile":"developer","features":{"enabled":["team-mode"]}}"#,
578                    ))
579                    .unwrap(),
580            )
581            .await
582            .unwrap();
583
584        assert_eq!(response.status(), HttpStatusCode::OK);
585        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
586            .await
587            .unwrap();
588        let payload: Value = serde_json::from_slice(&body).unwrap();
589        assert_eq!(payload["scene"]["name"], "team");
590        assert_eq!(payload["scene"]["schema_version"], 1);
591        assert_eq!(
592            payload["scene"]["features"]["enabled"],
593            serde_json::json!(["team-mode"])
594        );
595        let scene_file = std::fs::read_to_string(instance_dir.join("scenes").join("team.toml"))
596            .expect("scene file written");
597        assert!(scene_file.contains("name = \"team\""));
598        assert!(scene_file.contains("profile = \"developer\""));
599    }
600
601    #[tokio::test]
602    async fn bind_scene_updates_active_scene_in_config() {
603        let root = tempdir().expect("temp dir");
604        let repo_root = root.path().to_path_buf();
605        let instance_dir = repo_root.join(".weft").join("weft-claw");
606        let scenes_dir = instance_dir.join("scenes");
607        std::fs::create_dir_all(&scenes_dir).expect("scene dir created");
608        std::fs::write(
609            instance_dir.join("config.toml"),
610            "schema_version = 1\nactive_scene = \"stable\"\n",
611        )
612        .expect("config written");
613        std::fs::write(
614            scenes_dir.join("team.toml"),
615            "schema_version = 1\nname = \"team\"\ndescription = \"Team scene\"\n",
616        )
617        .expect("scene file written");
618
619        let mut apps = ResolvedAppMap::new();
620        apps.insert(
621            "weft-claw".into(),
622            resolved_app("weft-claw", Some(&instance_dir.join("config.toml"))),
623        );
624
625        let response = build_router(test_state(repo_root, apps))
626            .oneshot(
627                Request::builder()
628                    .method("POST")
629                    .uri("/api/apps/weft-claw/scenes/team/bind")
630                    .header("content-type", "application/json")
631                    .body(Body::from("{}"))
632                    .unwrap(),
633            )
634            .await
635            .unwrap();
636
637        assert_eq!(response.status(), HttpStatusCode::OK);
638        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
639            .await
640            .unwrap();
641        let payload: Value = serde_json::from_slice(&body).unwrap();
642        assert_eq!(payload["active_scene"], "team");
643        assert_eq!(payload["scene"]["name"], "team");
644        let config_file =
645            std::fs::read_to_string(instance_dir.join("config.toml")).expect("config file updated");
646        assert!(config_file.contains("active_scene = \"team\""));
647    }
648
649    #[tokio::test]
650    async fn list_scenes_returns_empty_when_config_is_unavailable() {
651        let root = tempdir().expect("temp dir");
652        let repo_root = root.path().to_path_buf();
653        let missing_config = repo_root
654            .join(".weft")
655            .join("weft-claw")
656            .join("config.toml");
657
658        let mut apps = ResolvedAppMap::new();
659        apps.insert(
660            "weft-claw".into(),
661            resolved_app("weft-claw", Some(&missing_config)),
662        );
663
664        let response = build_router(test_state(repo_root, apps))
665            .oneshot(
666                Request::builder()
667                    .uri("/api/apps/weft-claw/scenes")
668                    .body(Body::empty())
669                    .unwrap(),
670            )
671            .await
672            .unwrap();
673
674        assert_eq!(response.status(), HttpStatusCode::OK);
675        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
676            .await
677            .unwrap();
678        let payload: Value = serde_json::from_slice(&body).unwrap();
679        assert_eq!(payload["app"], "weft-claw");
680        assert_eq!(payload["scenes"], serde_json::json!([]));
681        assert_eq!(payload["active_scene"], "");
682    }
683}