Skip to main content

wsx_core/integration/
mod.rs

1//! Installation and discovery for wsx-owned coding-agent adapters.
2//!
3//! Vendor hook schemas are mirrored here, but assets only invoke `wsx agent
4//! report` and trust lifecycle events emitted by each provider.
5
6// ^ [[Session Model]] crates/wsx-core/src/integration/mod.rs -> crates/wsx-core/src/runtime/domain.rs, crates/wsx-core/integrations/pi/wsx-agent-status.ts
7mod assets;
8mod availability;
9mod config_edit;
10mod install;
11mod model;
12mod opencode_config;
13mod paths;
14pub mod resume;
15mod status;
16
17pub use availability::is_available;
18pub use install::install;
19pub use model::{
20    InstallResult, InstallStatus, IntegrationMetadata, IntegrationTarget, LifecycleCapability,
21};
22pub use status::{metadata, scan};
23
24#[cfg(test)]
25mod tests {
26    use super::*;
27    use serde_json::Value;
28    use std::fs;
29    use std::path::PathBuf;
30
31    fn test_root(name: &str) -> PathBuf {
32        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
33            .join("../../target/wsx-core-integration-tests")
34            .join(format!(
35                "{name}-{}-{}",
36                std::process::id(),
37                std::time::SystemTime::now()
38                    .duration_since(std::time::UNIX_EPOCH)
39                    .unwrap()
40                    .as_nanos()
41            ));
42        fs::create_dir_all(&path).unwrap();
43        path
44    }
45
46    #[test]
47    fn enum_is_complete() {
48        assert_eq!(IntegrationTarget::ALL.len(), 17);
49        assert_eq!(
50            IntegrationTarget::ALL.map(IntegrationTarget::cli_value),
51            [
52                "pi",
53                "omp",
54                "claude",
55                "codex",
56                "copilot",
57                "devin",
58                "droid",
59                "kimi",
60                "opencode",
61                "kilo",
62                "hermes",
63                "qodercli",
64                "qwen",
65                "cursor",
66                "mastracode",
67                "antigravity-cli",
68                "grok"
69            ]
70        );
71    }
72    #[test]
73    fn availability_and_status_are_path_injected() {
74        let root = test_root("availability");
75        let executable = root.join("claude");
76        fs::write(&executable, "#!/bin/sh\n").unwrap();
77        #[cfg(unix)]
78        {
79            use std::os::unix::fs::PermissionsExt;
80            fs::set_permissions(&executable, fs::Permissions::from_mode(0o700)).unwrap();
81        }
82        assert!(availability::available_on_path_for_test(
83            IntegrationTarget::Claude,
84            &root
85        ));
86        assert_eq!(
87            status::metadata_for_test(IntegrationTarget::Claude, &root)
88                .unwrap()
89                .install_status,
90            InstallStatus::Missing
91        );
92        fs::remove_dir_all(root).unwrap();
93    }
94
95    #[test]
96    fn safe_idempotent_install() {
97        let root = test_root("pi");
98        let first = install::install_for_test(IntegrationTarget::Pi, &root).unwrap();
99        let before = fs::read(&first.paths[0]).unwrap();
100        let second = install::install_for_test(IntegrationTarget::Pi, &root).unwrap();
101        assert_eq!(first.paths, second.paths);
102        assert_eq!(before, fs::read(&second.paths[0]).unwrap());
103        assert_eq!(
104            status::metadata_for_test(IntegrationTarget::Pi, &root)
105                .unwrap()
106                .install_status,
107            InstallStatus::Current
108        );
109        fs::remove_dir_all(root).unwrap();
110    }
111
112    #[test]
113    fn every_target_installs_idempotently_in_isolation() {
114        let parent = test_root("all-targets");
115        for target in IntegrationTarget::ALL {
116            let root = parent.join(target.cli_value());
117            fs::create_dir_all(&root).unwrap();
118            let first = install::install_for_test(target, &root).unwrap();
119            let second = install::install_for_test(target, &root).unwrap();
120            assert_eq!(first.paths, second.paths, "{target}");
121            assert_eq!(
122                status::metadata_for_test(target, &root)
123                    .unwrap()
124                    .install_status,
125                InstallStatus::Current,
126                "{target}"
127            );
128        }
129        let grok_hooks = fs::read_to_string(parent.join("grok/hooks/wsx.json")).unwrap();
130        assert!(grok_hooks.contains("SessionEnd") && grok_hooks.contains("detached"));
131        fs::remove_dir_all(parent).unwrap();
132    }
133
134    #[test]
135    fn omp_rejects_the_pi_extension_directory() {
136        let shared = test_root("shared-agent");
137        let omp = paths::asset_path_in(&shared, IntegrationTarget::Omp);
138        let pi = paths::asset_path_in(&shared, IntegrationTarget::Pi);
139
140        let error = install::validate_omp_directories_for_test(&omp, &pi).unwrap_err();
141
142        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
143        fs::remove_dir_all(shared).unwrap();
144    }
145
146    #[test]
147    fn invalid_config_does_not_publish_a_current_version_marker() {
148        let root = test_root("invalid-config");
149        fs::write(root.join("settings.json"), "[]\n").unwrap();
150
151        assert!(install::install_for_test(IntegrationTarget::Claude, &root).is_err());
152        assert!(!paths::asset_path_in(&root, IntegrationTarget::Claude).exists());
153        assert_eq!(
154            status::metadata_for_test(IntegrationTarget::Claude, &root)
155                .unwrap()
156                .install_status,
157            InstallStatus::Missing
158        );
159        fs::remove_dir_all(root).unwrap();
160    }
161
162    #[cfg(unix)]
163    #[test]
164    fn rejects_unsafe_destinations() {
165        use std::os::unix::fs::{symlink, PermissionsExt};
166        let dir = test_root("unsafe");
167        let target = dir.join("target");
168        fs::write(&target, "x").unwrap();
169        let link = dir.join("link");
170        symlink(&target, &link).unwrap();
171        assert_eq!(
172            install::atomic_write_for_test(&link, "x")
173                .unwrap_err()
174                .kind(),
175            std::io::ErrorKind::PermissionDenied
176        );
177        let bad = dir.join("bad");
178        fs::write(&bad, "x").unwrap();
179        fs::set_permissions(&bad, fs::Permissions::from_mode(0o666)).unwrap();
180        assert_eq!(
181            install::atomic_write_for_test(&bad, "x")
182                .unwrap_err()
183                .kind(),
184            std::io::ErrorKind::PermissionDenied
185        );
186        fs::remove_dir_all(dir).unwrap();
187    }
188    #[test]
189    fn representative_config_shapes() {
190        let p = PathBuf::from("target/wsx hook.sh");
191        let cfg = PathBuf::from("x.json");
192        let nested = config_edit::json_config(
193            IntegrationTarget::Claude,
194            r#"{"keep":1,"hooks":{"PermissionRequest":[{"hooks":[{"type":"command","command":"keep-permission-hook"},{"type":"command","command":"'target/wsx hook.sh' blocked"}]}],"StopFailure":[{"matcher":"custom","hooks":[{"type":"command","command":"keep-stop-failure"}]},{"matcher":"*","hooks":[{"type":"command","command":"'target/wsx hook.sh' working"}]}]}}"#,
195            &cfg,
196            &p,
197        )
198        .unwrap();
199        assert!(nested.contains("SessionStart") && nested.contains("\"keep\": 1"));
200        assert!(nested.contains("keep-permission-hook"));
201        assert!(nested.contains("keep-stop-failure"));
202        let claude: Value = serde_json::from_str(&nested).unwrap();
203        let stop_failures = claude["hooks"]["StopFailure"].as_array().unwrap();
204        assert_eq!(stop_failures.len(), 3);
205        for (matcher, action) in [
206            ("rate_limit", "blocked"),
207            (
208                "overloaded|authentication_failed|oauth_org_not_allowed|account_on_hold|billing_error|invalid_request|model_not_found|server_error|max_output_tokens|unknown",
209                "error",
210            ),
211        ] {
212            assert!(stop_failures.iter().any(|entry| {
213                entry["matcher"] == matcher
214                    && entry["hooks"].as_array().is_some_and(|hooks| {
215                        hooks.iter().any(|hook| {
216                            hook["command"] == config_edit::command(&p, action)
217                        })
218                    })
219            }));
220        }
221        assert!(!stop_failures.iter().any(|entry| {
222            entry["hooks"].as_array().is_some_and(|hooks| {
223                hooks
224                    .iter()
225                    .any(|hook| hook["command"] == config_edit::command(&p, "working"))
226            })
227        }));
228        for (event, action) in [
229            ("SessionStart", "idle"),
230            ("SessionEnd", "detached"),
231            ("UserPromptSubmit", "working"),
232            ("Stop", "done"),
233        ] {
234            assert!(nested.contains(event), "missing Claude event {event}");
235            assert!(
236                nested.contains(&config_edit::command(&p, action)),
237                "missing Claude action {action}"
238            );
239        }
240        assert_eq!(
241            config_edit::json_config(IntegrationTarget::Claude, &nested, &cfg, &p).unwrap(),
242            nested
243        );
244        assert!(config_edit::json_config(IntegrationTarget::Claude, "[]", &cfg, &p).is_err());
245        let codex_hooks = config_edit::json_config(
246            IntegrationTarget::Codex,
247            r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"keep-me"},{"type":"command","command":"'target/wsx hook.sh' session"}]}]}}"#,
248            &cfg,
249            &p,
250        )
251        .unwrap();
252        for (event, action) in [
253            ("SessionStart", "idle"),
254            ("UserPromptSubmit", "working"),
255            ("PreToolUse", "working"),
256            ("PermissionRequest", "blocked"),
257            ("PostToolUse", "working"),
258            ("Stop", "done"),
259            ("Interrupt", "idle"),
260        ] {
261            assert!(codex_hooks.contains(event), "missing Codex event {event}");
262            assert!(codex_hooks.contains(&config_edit::command(&p, action)));
263        }
264        assert!(codex_hooks.contains("keep-me"));
265        assert!(!codex_hooks.contains("'target/wsx hook.sh' session"));
266        assert_eq!(
267            config_edit::json_config(IntegrationTarget::Codex, &codex_hooks, &cfg, &p).unwrap(),
268            codex_hooks
269        );
270        let direct = config_edit::json_config(IntegrationTarget::Copilot, "{}", &cfg, &p).unwrap();
271        assert!(direct.contains("\"bash\""));
272        let simple = config_edit::json_config(IntegrationTarget::Cursor, "{}", &cfg, &p).unwrap();
273        assert!(simple.contains("sessionStart"));
274        let flat = config_edit::json_config(IntegrationTarget::Mastracode, "{}", &cfg, &p).unwrap();
275        assert!(flat.contains("PermissionRequest"));
276        let named = config_edit::json_config(
277            IntegrationTarget::AntigravityCli,
278            r#"{"other":{}}"#,
279            &cfg,
280            &p,
281        )
282        .unwrap();
283        assert!(named.contains("PreInvocation") && named.contains("other"));
284        let codex = config_edit::codex_toml("model = \"x\"\n");
285        assert!(codex.contains("[features]\nhooks = true"));
286        let kimi = config_edit::kimi_toml("model = \"x\"\n", &p);
287        assert!(kimi.contains("PermissionRequest") && kimi.contains("model"));
288        let yaml = config_edit::hermes_yaml("theme: dark\n");
289        assert!(yaml.contains("wsx-agent-status") && yaml.contains("theme"));
290    }
291
292    #[test]
293    fn primary_assets_report_version_and_native_session_ids() {
294        for target in IntegrationTarget::ALL {
295            let asset = assets::primary(target);
296            let marker = format!("WSX_INTEGRATION_VERSION={}", target.expected_version());
297            assert!(asset.contains(&marker), "{target}: missing {marker}");
298            assert!(
299                asset.contains("--session-id"),
300                "{target}: missing --session-id"
301            );
302            assert!(
303                !asset.contains("--conversation-id"),
304                "{target}: unexpected --conversation-id"
305            );
306        }
307    }
308
309    #[test]
310    fn lifecycle_capabilities_match_native_authoritative_adapters() {
311        assert!(assets::primary(IntegrationTarget::Claude)
312            .contains("[ \"claude\" = \"claude\" ] && set -- \"$@\" --escape-interrupts"));
313        let authoritative = IntegrationTarget::ALL
314            .into_iter()
315            .filter(|target| target.lifecycle() == LifecycleCapability::Authoritative)
316            .collect::<Vec<_>>();
317        assert_eq!(
318            authoritative,
319            [
320                IntegrationTarget::Pi,
321                IntegrationTarget::Omp,
322                IntegrationTarget::Claude,
323                IntegrationTarget::Codex,
324                IntegrationTarget::Kimi,
325                IntegrationTarget::Opencode,
326                IntegrationTarget::Kilo,
327                IntegrationTarget::Mastracode,
328            ]
329        );
330    }
331
332    #[test]
333    fn authoritative_assets_and_configs_report_completion() {
334        let pi = assets::primary(IntegrationTarget::Pi);
335        assert!(pi.contains("SETTLEMENT_DELAY_MS = 25"));
336        assert!(pi.contains("BLOCKING_UI_METHODS"));
337        assert!(pi.contains("observeBlockingUi(ctx.ui, updateBlocked)"));
338        assert!(
339            pi.contains("Promise.resolve(Reflect.apply(original, uiValue, args)).finally(release)")
340        );
341        assert!(pi.contains("restoreBlockingUi?.();"));
342        assert!(pi.contains("clearPendingSettlement();"));
343        assert!(pi.contains("ctx.isIdle() === false"));
344        assert!(pi.contains("report(settledRunAborted ? \"idle\" : \"done\", settledSessionRef)"));
345        assert!(pi.contains("stopReason === \"aborted\""));
346        assert!(pi.contains("pi.on(\"session_shutdown\""));
347        assert!(pi.contains("await flushReports();"));
348        assert!(pi.contains("--detached"));
349        let omp = assets::primary(IntegrationTarget::Omp);
350        assert!(omp.contains("report(\"done\", ctx)"));
351        assert!(omp.contains("pi.on(\"session_shutdown\""));
352        assert!(omp.contains("await flushReports();"));
353        assert!(omp.contains("--detached"));
354        for target in [IntegrationTarget::Opencode, IntegrationTarget::Kilo] {
355            assert!(
356                assets::primary(target).contains("activeSessions.has(id) ? \"done\" : \"idle\"")
357            );
358        }
359        let hook = PathBuf::from("wsx-agent-status.sh");
360        let kimi = config_edit::kimi_toml("", &hook);
361        assert!(kimi.contains("'done'") || kimi.contains(" done"));
362        let codex = config_edit::json_config(
363            IntegrationTarget::Codex,
364            "{}",
365            PathBuf::from("hooks.json").as_path(),
366            &hook,
367        )
368        .unwrap();
369        assert!(codex.contains("blocked") && codex.contains("done"));
370        let mastra = config_edit::json_config(
371            IntegrationTarget::Mastracode,
372            "{}",
373            PathBuf::from("hooks.json").as_path(),
374            &hook,
375        )
376        .unwrap();
377        assert!(mastra.contains("done"));
378    }
379
380    #[test]
381    fn teardown_capable_adapters_report_detached() {
382        let hook = PathBuf::from("wsx-agent-status.sh");
383        for target in [
384            IntegrationTarget::Claude,
385            IntegrationTarget::Codex,
386            IntegrationTarget::Copilot,
387            IntegrationTarget::Devin,
388            IntegrationTarget::Droid,
389            IntegrationTarget::Qodercli,
390            IntegrationTarget::Qwen,
391            IntegrationTarget::Cursor,
392            IntegrationTarget::Mastracode,
393        ] {
394            let config = config_edit::json_config(
395                target,
396                "{}",
397                PathBuf::from("hooks.json").as_path(),
398                &hook,
399            )
400            .unwrap();
401            assert!(config.contains("detached"), "{target}: missing detach hook");
402        }
403        assert!(config_edit::kimi_toml("", &hook).contains("detached"));
404        assert!(assets::primary(IntegrationTarget::Hermes).contains("on_session_finalize"));
405    }
406
407    #[test]
408    fn working_adapters_renew_wake_authority() {
409        for target in [
410            IntegrationTarget::Pi,
411            IntegrationTarget::Omp,
412            IntegrationTarget::Opencode,
413            IntegrationTarget::Kilo,
414        ] {
415            let asset = assets::primary(target);
416            assert!(
417                asset.contains("300_000"),
418                "{target}: missing wake heartbeat"
419            );
420            assert!(
421                asset.contains("sendInFlight"),
422                "{target}: reports must serialize"
423            );
424            assert!(
425                asset.contains("report(\"working\""),
426                "{target}: heartbeat must renew Working"
427            );
428        }
429    }
430
431    #[test]
432    fn pi_and_omp_primary_assets_prefer_session_paths_and_report_lifecycle() {
433        for target in [IntegrationTarget::Pi, IntegrationTarget::Omp] {
434            let asset = assets::primary(target);
435            let path = asset
436                .find("--session-path")
437                .expect("session path assertion requires a path branch");
438            let id = asset
439                .find("--session-id")
440                .expect("session path assertion requires an ID branch");
441            assert!(path < id, "{target}: path branch must precede ID branch");
442            assert!(
443                asset.contains(&format!("\"--provider\", \"{}\"", target.cli_value())),
444                "{target}: missing exact provider reporting"
445            );
446            assert!(
447                asset.contains("--lifecycle"),
448                "{target}: missing lifecycle reporting"
449            );
450        }
451    }
452
453    #[test]
454    fn opencode_assets_use_tui_routing_and_argv_reporting() {
455        assert!(assets::OPENCODE_TUI.contains("api.route.current"));
456        assert!(assets::OPENCODE_TUI.contains("execFile"));
457        assert!(!assets::OPENCODE_TUI.contains("createConnection"));
458        let updated = opencode_config::register_tui("{\n  // keep\n  \"plugin\": []\n}\n").unwrap();
459        assert!(updated.contains("// keep"));
460        assert!(updated.contains("./wsx-tui-session.js"));
461    }
462}