| 1 | //! Live smoke tests against the real Onde Cloud endpoint (cloud.ondeinference.com). |
| 2 | //! |
| 3 | //! Skipped unless `ONDE_API_KEY` (format `app-id:app-secret`) is set: they hit the production |
| 4 | //! service and cost real tokens, so they are for manual or scheduled verification. |
| 5 | //! |
| 6 | //! Run with: ONDE_API_KEY=app-id:app-secret cargo test --test live -- --nocapture |
| 7 | |
| 8 | use std::sync::{Arc, Mutex}; |
| 9 | |
| 10 | const BIN: &str = env!("CARGO_BIN_EXE_splitfire-agent"); |
| 11 | |
| 12 | fn live_key() -> Option<String> { |
| 13 | std::env::var("ONDE_API_KEY").ok().filter(|k| !k.is_empty()) |
| 14 | } |
| 15 | |
| 16 | /// A clean command: no stored config, no overrides leaking in from the developer's shell. |
| 17 | fn clean_cmd(data_dir: &std::path::Path) -> std::process::Command { |
| 18 | let mut cmd = std::process::Command::new(BIN); |
| 19 | cmd.env("SPLITFIRE_DATA_DIR", data_dir) |
| 20 | .env("SPLITFIRE_MCP_DEMUCS", "off") |
| 21 | .env_remove("SPLITFIRE_BASE_URL") |
| 22 | .env_remove("SPLITFIRE_MODEL") |
| 23 | .env_remove("SPLITFIRE_MODELS"); |
| 24 | cmd |
| 25 | } |
| 26 | |
| 27 | #[test] |
| 28 | fn onde_lists_models() { |
| 29 | if live_key().is_none() { |
| 30 | eprintln!("skipping: ONDE_API_KEY not set"); |
| 31 | return; |
| 32 | } |
| 33 | let dir = std::env::temp_dir().join(format!("sf-live-{}", uuid::Uuid::new_v4())); |
| 34 | let out = clean_cmd(&dir) |
| 35 | .arg("--list-models") |
| 36 | .output() |
| 37 | .expect("failed to run splitfire-agent --list-models"); |
| 38 | let stdout = String::from_utf8_lossy(&out.stdout); |
| 39 | let stderr = String::from_utf8_lossy(&out.stderr); |
| 40 | assert!( |
| 41 | out.status.success(), |
| 42 | "--list-models failed\nstdout: {stdout}\nstderr: {stderr}" |
| 43 | ); |
| 44 | let ids: Vec<&str> = stdout |
| 45 | .lines() |
| 46 | .map(|l| l.split('\t').next().unwrap()) |
| 47 | .collect(); |
| 48 | assert!(!ids.is_empty(), "endpoint returned no models"); |
| 49 | assert!( |
| 50 | ids.iter().any(|id| id.starts_with("onde")), |
| 51 | "expected at least one onde-* model, got: {ids:?}" |
| 52 | ); |
| 53 | } |
| 54 | |
| 55 | /// One completion through the ACP path that must use a theory tool: ask for the C# major |
| 56 | /// scale and expect the exact spelling (E# and B#) in the answer. |
| 57 | #[tokio::test(flavor = "multi_thread")] |
| 58 | async fn onde_answers_with_theory_tool() { |
| 59 | use agent_client_protocol::schema::ProtocolVersion; |
| 60 | use agent_client_protocol::schema::v1::{ |
| 61 | ContentBlock, InitializeRequest, NewSessionRequest, PromptRequest, |
| 62 | RequestPermissionRequest, SessionNotification, SessionUpdate, StopReason, TextContent, |
| 63 | }; |
| 64 | use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, Client, ConnectionTo}; |
| 65 | |
| 66 | if live_key().is_none() { |
| 67 | eprintln!("skipping: ONDE_API_KEY not set"); |
| 68 | return; |
| 69 | } |
| 70 | let dir = std::env::temp_dir().join(format!("sf-live-{}", uuid::Uuid::new_v4())); |
| 71 | let text: Arc<Mutex<String>> = Arc::default(); |
| 72 | let text_notify = text.clone(); |
| 73 | let tools: Arc<Mutex<Vec<String>>> = Arc::default(); |
| 74 | let tools_notify = tools.clone(); |
| 75 | |
| 76 | let stop_reason = Client |
| 77 | .builder() |
| 78 | .on_receive_notification( |
| 79 | async move |n: SessionNotification, _cx| { |
| 80 | match n.update { |
| 81 | SessionUpdate::AgentMessageChunk(chunk) => { |
| 82 | if let ContentBlock::Text(t) = chunk.content { |
| 83 | text_notify.lock().unwrap().push_str(&t.text); |
| 84 | } |
| 85 | } |
| 86 | SessionUpdate::ToolCall(tc) => tools_notify.lock().unwrap().push(tc.title), |
| 87 | _ => {} |
| 88 | } |
| 89 | Ok(()) |
| 90 | }, |
| 91 | agent_client_protocol::on_receive_notification!(), |
| 92 | ) |
| 93 | .on_receive_request( |
| 94 | // Theory tools are read-only; a permission request means something else ran. |
| 95 | async move |_req: RequestPermissionRequest, responder, _cx| { |
| 96 | responder.respond_with_error(agent_client_protocol::Error::internal_error()) |
| 97 | }, |
| 98 | agent_client_protocol::on_receive_request!(), |
| 99 | ) |
| 100 | .connect_with( |
| 101 | AcpAgent::new( |
| 102 | AcpAgentConfig::new(BIN) |
| 103 | .env("SPLITFIRE_DATA_DIR", dir.display().to_string()) |
| 104 | .env("SPLITFIRE_MCP_DEMUCS", "off"), |
| 105 | ), |
| 106 | |connection: ConnectionTo<Agent>| async move { |
| 107 | connection |
| 108 | .send_request(InitializeRequest::new(ProtocolVersion::V1)) |
| 109 | .block_task() |
| 110 | .await?; |
| 111 | let session = connection |
| 112 | .send_request(NewSessionRequest::new(std::env::current_dir().unwrap())) |
| 113 | .block_task() |
| 114 | .await?; |
| 115 | let resp = connection |
| 116 | .send_request(PromptRequest::new( |
| 117 | session.session_id, |
| 118 | vec![ContentBlock::Text(TextContent::new( |
| 119 | "Spell the C# major scale. Use your theory tool and list the seven notes.", |
| 120 | ))], |
| 121 | )) |
| 122 | .block_task() |
| 123 | .await?; |
| 124 | Ok(resp.stop_reason) |
| 125 | }, |
| 126 | ) |
| 127 | .await |
| 128 | .expect("ACP session failed"); |
| 129 | |
| 130 | assert_eq!(stop_reason, StopReason::EndTurn); |
| 131 | let text = text.lock().unwrap(); |
| 132 | eprintln!( |
| 133 | "tool calls: {:?}\nmodel replied: {}", |
| 134 | tools.lock().unwrap(), |
| 135 | text.trim() |
| 136 | ); |
| 137 | assert!( |
| 138 | text.contains("E#") && text.contains("B#"), |
| 139 | "expected exact spelling: {text}" |
| 140 | ); |
| 141 | } |