chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
@@ -0,0 +1,550 @@
|
||||
use anyhow::Result;
|
||||
use jcode::auth::{AuthState, AuthStatus};
|
||||
use jcode::provider::Provider;
|
||||
use jcode::provider_catalog::{
|
||||
OPENAI_COMPAT_LOGIN_PROVIDER, login_providers, openai_compatible_profiles,
|
||||
};
|
||||
use jcode::tui::login_picker::{LoginPicker, LoginPickerItem, LoginPickerSummary};
|
||||
use jcode_provider_openrouter_runtime::OpenRouterProvider;
|
||||
use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
|
||||
use std::collections::HashSet;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpListener;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex, MutexGuard, OnceLock, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn lock_env() -> MutexGuard<'static, ()> {
|
||||
ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
}
|
||||
|
||||
fn tracked_env_vars() -> Vec<String> {
|
||||
let mut keys: HashSet<String> = [
|
||||
"HOME",
|
||||
"APPDATA",
|
||||
"XDG_CONFIG_HOME",
|
||||
"JCODE_HOME",
|
||||
"NO_PROXY",
|
||||
"no_proxy",
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_OPENROUTER_PROVIDER_FEATURES",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_OPENROUTER_MODEL_CATALOG",
|
||||
"JCODE_OPENROUTER_MODEL",
|
||||
"JCODE_OPENROUTER_STATIC_MODELS",
|
||||
"JCODE_OPENROUTER_AUTH_HEADER",
|
||||
"JCODE_OPENROUTER_AUTH_HEADER_NAME",
|
||||
"JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER",
|
||||
"JCODE_OPENROUTER_PROVIDER",
|
||||
"JCODE_OPENROUTER_NO_FALLBACK",
|
||||
"OPENROUTER_API_KEY",
|
||||
"AUTH_FLOW_TEST_KEY",
|
||||
]
|
||||
.into_iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
for profile in openai_compatible_profiles() {
|
||||
keys.insert(profile.api_key_env.to_string());
|
||||
}
|
||||
|
||||
let mut keys: Vec<_> = keys.into_iter().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
}
|
||||
|
||||
struct TestEnv {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
saved: Vec<(String, Option<String>)>,
|
||||
temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestEnv {
|
||||
fn new() -> Result<Self> {
|
||||
let lock = lock_env();
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("jcode-auth-flow-")
|
||||
.tempdir()?;
|
||||
let saved = tracked_env_vars()
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let value = std::env::var(&key).ok();
|
||||
(key, value)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (key, _) in &saved {
|
||||
jcode::env::remove_var(key);
|
||||
}
|
||||
|
||||
jcode::env::set_var("HOME", temp.path());
|
||||
jcode::env::set_var("XDG_CONFIG_HOME", temp.path().join("config"));
|
||||
jcode::env::set_var("APPDATA", temp.path().join("AppData").join("Roaming"));
|
||||
jcode::env::set_var("JCODE_HOME", temp.path().join("jcode-home"));
|
||||
jcode::env::set_var("NO_PROXY", "127.0.0.1,localhost");
|
||||
jcode::env::set_var("no_proxy", "127.0.0.1,localhost");
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
Ok(Self {
|
||||
_lock: lock,
|
||||
saved,
|
||||
temp,
|
||||
})
|
||||
}
|
||||
|
||||
fn configure_openai_compatible_runtime(
|
||||
&self,
|
||||
api_base: &str,
|
||||
cache_namespace: &str,
|
||||
key: Option<&str>,
|
||||
allow_no_auth: bool,
|
||||
) {
|
||||
let _ = self.temp.path();
|
||||
jcode::env::set_var("JCODE_OPENROUTER_API_BASE", api_base);
|
||||
jcode::env::set_var("JCODE_OPENROUTER_API_KEY_NAME", "AUTH_FLOW_TEST_KEY");
|
||||
jcode::env::set_var("JCODE_OPENROUTER_ENV_FILE", "auth-flow-test.env");
|
||||
jcode::env::set_var("JCODE_OPENROUTER_CACHE_NAMESPACE", cache_namespace);
|
||||
jcode::env::set_var("JCODE_OPENROUTER_PROVIDER_FEATURES", "0");
|
||||
jcode::env::set_var("JCODE_OPENROUTER_MODEL_CATALOG", "1");
|
||||
if let Some(key) = key {
|
||||
jcode::env::set_var("AUTH_FLOW_TEST_KEY", key);
|
||||
} else {
|
||||
jcode::env::remove_var("AUTH_FLOW_TEST_KEY");
|
||||
}
|
||||
if allow_no_auth {
|
||||
jcode::env::set_var("JCODE_OPENROUTER_ALLOW_NO_AUTH", "1");
|
||||
} else {
|
||||
jcode::env::remove_var("JCODE_OPENROUTER_ALLOW_NO_AUTH");
|
||||
}
|
||||
AuthStatus::invalidate_cache();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnv {
|
||||
fn drop(&mut self) {
|
||||
AuthStatus::invalidate_cache();
|
||||
for (key, value) in &self.saved {
|
||||
if let Some(value) = value {
|
||||
jcode::env::set_var(key, value);
|
||||
} else {
|
||||
jcode::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
AuthStatus::invalidate_cache();
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeModelsServer {
|
||||
api_base: String,
|
||||
requests: mpsc::Receiver<String>,
|
||||
request_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
fn spawn_models_server(
|
||||
max_requests: usize,
|
||||
body: impl Into<String>,
|
||||
delay: Duration,
|
||||
) -> FakeModelsServer {
|
||||
spawn_raw_models_server(max_requests, "200 OK", body, delay)
|
||||
}
|
||||
|
||||
fn spawn_raw_models_server(
|
||||
max_requests: usize,
|
||||
status: impl Into<String>,
|
||||
body: impl Into<String>,
|
||||
delay: Duration,
|
||||
) -> FakeModelsServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake provider server");
|
||||
listener
|
||||
.set_nonblocking(true)
|
||||
.expect("set fake server nonblocking");
|
||||
let addr = listener.local_addr().expect("fake provider addr");
|
||||
let (request_tx, request_rx) = mpsc::channel();
|
||||
let request_count = Arc::new(AtomicUsize::new(0));
|
||||
let request_count_thread = Arc::clone(&request_count);
|
||||
let body = body.into();
|
||||
let status = status.into();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while request_count_thread.load(Ordering::SeqCst) < max_requests
|
||||
&& Instant::now() < deadline
|
||||
{
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(2)))
|
||||
.expect("set read timeout");
|
||||
let mut request = vec![0u8; 8192];
|
||||
let n = stream.read(&mut request).unwrap_or(0);
|
||||
let request = String::from_utf8_lossy(&request[..n]).into_owned();
|
||||
request_count_thread.fetch_add(1, Ordering::SeqCst);
|
||||
let _ = request_tx.send(request);
|
||||
|
||||
if !delay.is_zero() {
|
||||
std::thread::sleep(delay);
|
||||
}
|
||||
let response = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
||||
status,
|
||||
body.len(),
|
||||
body
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
FakeModelsServer {
|
||||
api_base: format!("http://{addr}/v1"),
|
||||
requests: request_rx,
|
||||
request_count,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_models_request(request: &str) {
|
||||
assert!(
|
||||
request.starts_with("GET /v1/models "),
|
||||
"expected GET /v1/models request, got: {request}"
|
||||
);
|
||||
}
|
||||
|
||||
fn lower_headers(request: &str) -> String {
|
||||
request.to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn run_current_thread<T>(future: impl std::future::Future<Output = T>) -> T {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("runtime")
|
||||
.block_on(future)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_login_provider_has_auth_status_and_tui_copy_metadata() -> Result<()> {
|
||||
let _env = TestEnv::new()?;
|
||||
let status = AuthStatus::default();
|
||||
|
||||
for provider in login_providers() {
|
||||
assert!(!provider.id.trim().is_empty(), "provider id must be set");
|
||||
assert!(
|
||||
!provider.display_name.trim().is_empty(),
|
||||
"{} display name must be set",
|
||||
provider.id
|
||||
);
|
||||
assert!(
|
||||
!provider.menu_detail.trim().is_empty(),
|
||||
"{} setup copy must be set",
|
||||
provider.id
|
||||
);
|
||||
assert!(
|
||||
!provider.auth_kind.label().trim().is_empty(),
|
||||
"{} auth kind label must be set",
|
||||
provider.id
|
||||
);
|
||||
assert!(
|
||||
!status
|
||||
.method_detail_for_provider(*provider)
|
||||
.trim()
|
||||
.is_empty(),
|
||||
"{} detected setup copy must be renderable",
|
||||
provider.id
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_models_contract_uses_models_endpoint_and_bearer_auth() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
let server = spawn_models_server(
|
||||
1,
|
||||
r#"{"data":[{"id":"bearer-contract-model","object":"model"}]}"#,
|
||||
Duration::ZERO,
|
||||
);
|
||||
env.configure_openai_compatible_runtime(
|
||||
&server.api_base,
|
||||
"auth-flow-bearer-contract",
|
||||
Some("sk-bearer-contract"),
|
||||
false,
|
||||
);
|
||||
|
||||
let provider = OpenRouterProvider::new()?;
|
||||
let models = run_current_thread(provider.fetch_models())?;
|
||||
assert_eq!(models[0].id, "bearer-contract-model");
|
||||
|
||||
let request = server.requests.recv_timeout(Duration::from_secs(2))?;
|
||||
assert_models_request(&request);
|
||||
let headers = lower_headers(&request);
|
||||
assert!(
|
||||
headers.contains("authorization: bearer sk-bearer-contract"),
|
||||
"missing bearer auth header: {request}"
|
||||
);
|
||||
assert!(
|
||||
provider
|
||||
.available_models_display()
|
||||
.iter()
|
||||
.any(|model| model == "bearer-contract-model"),
|
||||
"fetched model should be visible immediately after refresh"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_models_contract_supports_api_key_header_mode() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
let server = spawn_models_server(
|
||||
1,
|
||||
r#"{"data":[{"id":"header-contract-model","object":"model"}]}"#,
|
||||
Duration::ZERO,
|
||||
);
|
||||
env.configure_openai_compatible_runtime(
|
||||
&server.api_base,
|
||||
"auth-flow-header-contract",
|
||||
Some("sk-header-contract"),
|
||||
false,
|
||||
);
|
||||
jcode::env::set_var("JCODE_OPENROUTER_AUTH_HEADER", "api-key");
|
||||
jcode::env::set_var("JCODE_OPENROUTER_AUTH_HEADER_NAME", "x-api-key");
|
||||
|
||||
let provider = OpenRouterProvider::new()?;
|
||||
let models = run_current_thread(provider.fetch_models())?;
|
||||
assert_eq!(models[0].id, "header-contract-model");
|
||||
|
||||
let request = server.requests.recv_timeout(Duration::from_secs(2))?;
|
||||
assert_models_request(&request);
|
||||
let headers = lower_headers(&request);
|
||||
assert!(
|
||||
headers.contains("x-api-key: sk-header-contract"),
|
||||
"missing x-api-key auth header: {request}"
|
||||
);
|
||||
assert!(
|
||||
!headers.contains("authorization: bearer"),
|
||||
"api-key mode must not also send bearer auth: {request}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_no_auth_models_contract_sends_no_auth_header() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
let server = spawn_models_server(
|
||||
1,
|
||||
r#"{"data":[{"id":"local-no-auth-model","object":"model"}]}"#,
|
||||
Duration::ZERO,
|
||||
);
|
||||
env.configure_openai_compatible_runtime(
|
||||
&server.api_base,
|
||||
"auth-flow-local-no-auth-contract",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
|
||||
let provider = OpenRouterProvider::new()?;
|
||||
let models = run_current_thread(provider.fetch_models())?;
|
||||
assert_eq!(models[0].id, "local-no-auth-model");
|
||||
|
||||
let request = server.requests.recv_timeout(Duration::from_secs(2))?;
|
||||
assert_models_request(&request);
|
||||
let headers = lower_headers(&request);
|
||||
assert!(
|
||||
!headers.contains("authorization:"),
|
||||
"local no-auth endpoint should not send Authorization: {request}"
|
||||
);
|
||||
assert!(
|
||||
!headers.contains("api-key:") && !headers.contains("x-api-key:"),
|
||||
"local no-auth endpoint should not send API-key headers: {request}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_picker_cache_miss_schedules_single_background_refresh_and_updates_routes() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
let server = spawn_models_server(
|
||||
2,
|
||||
r#"{"data":[{"id":"background-race-live-model","object":"model"}]}"#,
|
||||
Duration::from_millis(25),
|
||||
);
|
||||
env.configure_openai_compatible_runtime(
|
||||
&server.api_base,
|
||||
"auth-flow-background-race",
|
||||
None,
|
||||
true,
|
||||
);
|
||||
jcode::env::set_var("JCODE_OPENROUTER_MODEL", "background-race-selected-model");
|
||||
|
||||
let provider = OpenRouterProvider::new()?;
|
||||
run_current_thread(async {
|
||||
let first = provider.available_models_display();
|
||||
let second = provider.available_models_display();
|
||||
assert!(
|
||||
first
|
||||
.iter()
|
||||
.any(|model| model == "background-race-selected-model")
|
||||
);
|
||||
assert!(
|
||||
second
|
||||
.iter()
|
||||
.any(|model| model == "background-race-selected-model")
|
||||
);
|
||||
|
||||
for _ in 0..100 {
|
||||
if provider
|
||||
.available_models_display()
|
||||
.iter()
|
||||
.any(|model| model == "background-race-live-model")
|
||||
{
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
|
||||
let display = provider.available_models_display();
|
||||
assert!(
|
||||
display
|
||||
.iter()
|
||||
.any(|model| model == "background-race-live-model"),
|
||||
"background refresh should update picker models; display={display:?}"
|
||||
);
|
||||
let routes = provider.model_routes();
|
||||
assert!(
|
||||
routes.iter().any(|route| {
|
||||
route.model == "background-race-live-model"
|
||||
&& route.api_method == "openai-compatible"
|
||||
&& route.available
|
||||
}),
|
||||
"background refresh should update model routes; routes={routes:?}"
|
||||
);
|
||||
});
|
||||
|
||||
let request = server.requests.recv_timeout(Duration::from_secs(2))?;
|
||||
assert_models_request(&request);
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
assert_eq!(
|
||||
server.request_count.load(Ordering::SeqCst),
|
||||
1,
|
||||
"concurrent picker renders should coalesce into one background /models request"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_model_catalog_failure_keeps_static_and_selected_model_picker_fallbacks() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
let server = spawn_raw_models_server(
|
||||
1,
|
||||
"500 Internal Server Error",
|
||||
r#"{"error":"catalog temporarily unavailable"}"#,
|
||||
Duration::ZERO,
|
||||
);
|
||||
env.configure_openai_compatible_runtime(
|
||||
&server.api_base,
|
||||
"auth-flow-catalog-failure",
|
||||
Some("sk-catalog-failure"),
|
||||
false,
|
||||
);
|
||||
jcode::env::set_var("JCODE_OPENROUTER_MODEL", "selected-fallback-model");
|
||||
jcode::env::set_var(
|
||||
"JCODE_OPENROUTER_STATIC_MODELS",
|
||||
"selected-fallback-model\nstatic-fallback-model",
|
||||
);
|
||||
|
||||
let provider = OpenRouterProvider::new()?;
|
||||
let before = provider.available_models_display();
|
||||
assert!(
|
||||
before
|
||||
.iter()
|
||||
.any(|model| model == "selected-fallback-model")
|
||||
);
|
||||
assert!(before.iter().any(|model| model == "static-fallback-model"));
|
||||
|
||||
let err = run_current_thread(provider.fetch_models()).expect_err("catalog fetch should fail");
|
||||
let message = err.to_string();
|
||||
assert!(
|
||||
message.contains("500") && message.contains("catalog temporarily unavailable"),
|
||||
"unexpected catalog failure: {message}"
|
||||
);
|
||||
|
||||
let after = provider.available_models_display();
|
||||
assert!(
|
||||
after.iter().any(|model| model == "selected-fallback-model")
|
||||
&& after.iter().any(|model| model == "static-fallback-model"),
|
||||
"failed live catalog should not remove picker fallbacks; before={before:?} after={after:?}"
|
||||
);
|
||||
let request = server.requests.recv_timeout(Duration::from_secs(2))?;
|
||||
assert_models_request(&request);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn buffer_to_text(buffer: &Buffer) -> String {
|
||||
let area = buffer.area;
|
||||
let mut out = String::new();
|
||||
for y in area.y..area.y + area.height {
|
||||
for x in area.x..area.x + area.width {
|
||||
out.push_str(buffer[(x, y)].symbol());
|
||||
}
|
||||
out.push('\n');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_picker_renders_openai_compatible_setup_copy() -> Result<()> {
|
||||
let mut picker = LoginPicker::with_summary(
|
||||
" Login ",
|
||||
vec![LoginPickerItem::new(
|
||||
1,
|
||||
OPENAI_COMPAT_LOGIN_PROVIDER,
|
||||
AuthState::Available,
|
||||
"http://localhost:11434/v1 configured; no API key required",
|
||||
)],
|
||||
LoginPickerSummary {
|
||||
ready_count: 1,
|
||||
recommended_count: 0,
|
||||
..LoginPickerSummary::default()
|
||||
},
|
||||
);
|
||||
|
||||
let backend = TestBackend::new(120, 44);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.draw(|frame| picker.render(frame))?;
|
||||
let text = buffer_to_text(terminal.backend().buffer());
|
||||
|
||||
for expected in [
|
||||
"OpenAI-compatible",
|
||||
"/login openai-compatible",
|
||||
"API key / CLI",
|
||||
"Detected setup",
|
||||
"http://localhost:11434/v1 configured; no API key required",
|
||||
"custom endpoint setup: base URL first, then API key",
|
||||
"Press Enter to begin login.",
|
||||
] {
|
||||
assert!(
|
||||
text.contains(expected),
|
||||
"login picker should render expected copy {expected:?}; rendered:\n{text}"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 109 KiB |
@@ -0,0 +1,611 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
/// Test ambient state: load, save, record_cycle
|
||||
#[test]
|
||||
fn test_ambient_state_lifecycle() {
|
||||
use jcode::ambient::{AmbientCycleResult, AmbientState, AmbientStatus, CycleStatus};
|
||||
|
||||
let mut state = AmbientState::default();
|
||||
assert!(matches!(state.status, AmbientStatus::Idle));
|
||||
assert_eq!(state.total_cycles, 0);
|
||||
assert!(state.last_run.is_none());
|
||||
|
||||
// Record a cycle
|
||||
let result = AmbientCycleResult {
|
||||
summary: "Gardened 3 memories".to_string(),
|
||||
memories_modified: 3,
|
||||
compactions: 0,
|
||||
proactive_work: None,
|
||||
next_schedule: None,
|
||||
started_at: chrono::Utc::now(),
|
||||
ended_at: chrono::Utc::now(),
|
||||
status: CycleStatus::Complete,
|
||||
conversation: None,
|
||||
};
|
||||
|
||||
state.record_cycle(&result);
|
||||
assert_eq!(state.total_cycles, 1);
|
||||
assert!(state.last_run.is_some());
|
||||
assert_eq!(state.last_summary.as_deref(), Some("Gardened 3 memories"));
|
||||
assert_eq!(state.last_memories_modified, Some(3));
|
||||
assert_eq!(state.last_compactions, Some(0));
|
||||
// No next_schedule → should be Idle
|
||||
assert!(matches!(state.status, AmbientStatus::Idle));
|
||||
}
|
||||
|
||||
/// Test ambient scheduled queue: push, pop, priority ordering
|
||||
#[test]
|
||||
fn test_ambient_scheduled_queue() {
|
||||
use jcode::ambient::{Priority, ScheduledItem, ScheduledQueue};
|
||||
|
||||
let tmp = std::env::temp_dir().join("jcode-test-queue.json");
|
||||
let _ = std::fs::remove_file(&tmp); // Clean up from previous runs
|
||||
let mut queue = ScheduledQueue::load(tmp);
|
||||
assert!(queue.is_empty());
|
||||
|
||||
// Push items with different priorities
|
||||
let now = chrono::Utc::now();
|
||||
queue.push(ScheduledItem {
|
||||
id: "low_1".to_string(),
|
||||
scheduled_for: now - chrono::Duration::minutes(5),
|
||||
context: "low priority task".to_string(),
|
||||
priority: Priority::Low,
|
||||
target: jcode::ambient::ScheduleTarget::Ambient,
|
||||
created_by_session: "test".to_string(),
|
||||
created_at: now,
|
||||
working_dir: None,
|
||||
task_description: None,
|
||||
relevant_files: Vec::new(),
|
||||
git_branch: None,
|
||||
additional_context: None,
|
||||
});
|
||||
|
||||
queue.push(ScheduledItem {
|
||||
id: "high_1".to_string(),
|
||||
scheduled_for: now - chrono::Duration::minutes(5),
|
||||
context: "high priority task".to_string(),
|
||||
priority: Priority::High,
|
||||
target: jcode::ambient::ScheduleTarget::Ambient,
|
||||
created_by_session: "test".to_string(),
|
||||
created_at: now,
|
||||
working_dir: None,
|
||||
task_description: None,
|
||||
relevant_files: Vec::new(),
|
||||
git_branch: None,
|
||||
additional_context: None,
|
||||
});
|
||||
|
||||
queue.push(ScheduledItem {
|
||||
id: "future_1".to_string(),
|
||||
scheduled_for: now + chrono::Duration::hours(1),
|
||||
context: "future task".to_string(),
|
||||
priority: Priority::Normal,
|
||||
target: jcode::ambient::ScheduleTarget::Ambient,
|
||||
created_by_session: "test".to_string(),
|
||||
created_at: now,
|
||||
working_dir: None,
|
||||
task_description: None,
|
||||
relevant_files: Vec::new(),
|
||||
git_branch: None,
|
||||
additional_context: None,
|
||||
});
|
||||
|
||||
assert_eq!(queue.len(), 3);
|
||||
|
||||
// Pop ready items: should get high priority first, then low (future not ready)
|
||||
let ready = queue.pop_ready();
|
||||
assert_eq!(ready.len(), 2);
|
||||
assert_eq!(ready[0].id, "high_1"); // High priority first
|
||||
assert_eq!(ready[1].id, "low_1"); // Low priority second
|
||||
|
||||
// Future item still in queue
|
||||
assert_eq!(queue.len(), 1);
|
||||
assert_eq!(queue.items()[0].id, "future_1");
|
||||
}
|
||||
|
||||
/// Test adaptive scheduler: interval calculation
|
||||
#[test]
|
||||
fn test_adaptive_scheduler_intervals() {
|
||||
use jcode::ambient_scheduler::{AdaptiveScheduler, AmbientSchedulerConfig};
|
||||
|
||||
let config = AmbientSchedulerConfig {
|
||||
min_interval_minutes: 5,
|
||||
max_interval_minutes: 120,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scheduler = AdaptiveScheduler::new(config);
|
||||
|
||||
// With no rate limit info, should return max interval
|
||||
let interval = scheduler.calculate_interval(None);
|
||||
assert!(interval.as_secs() >= 120 * 60 - 1); // Allow 1s tolerance
|
||||
}
|
||||
|
||||
/// Test adaptive scheduler: backoff on rate limit
|
||||
#[test]
|
||||
fn test_adaptive_scheduler_backoff() {
|
||||
use jcode::ambient_scheduler::{AdaptiveScheduler, AmbientSchedulerConfig};
|
||||
|
||||
let config = AmbientSchedulerConfig {
|
||||
min_interval_minutes: 5,
|
||||
max_interval_minutes: 120,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut scheduler = AdaptiveScheduler::new(config);
|
||||
|
||||
let base_interval = scheduler.calculate_interval(None);
|
||||
|
||||
// Hit rate limit
|
||||
scheduler.on_rate_limit_hit();
|
||||
let backed_off = scheduler.calculate_interval(None);
|
||||
assert!(backed_off >= base_interval);
|
||||
|
||||
// Reset on success
|
||||
scheduler.on_successful_cycle();
|
||||
let after_reset = scheduler.calculate_interval(None);
|
||||
assert!(after_reset <= backed_off);
|
||||
}
|
||||
|
||||
/// Test adaptive scheduler: pause on active session
|
||||
#[test]
|
||||
fn test_adaptive_scheduler_pause() {
|
||||
use jcode::ambient_scheduler::{AdaptiveScheduler, AmbientSchedulerConfig};
|
||||
|
||||
let config = AmbientSchedulerConfig {
|
||||
min_interval_minutes: 5,
|
||||
max_interval_minutes: 120,
|
||||
pause_on_active_session: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut scheduler = AdaptiveScheduler::new(config);
|
||||
|
||||
assert!(!scheduler.should_pause());
|
||||
scheduler.set_user_active(true);
|
||||
assert!(scheduler.should_pause());
|
||||
scheduler.set_user_active(false);
|
||||
assert!(!scheduler.should_pause());
|
||||
}
|
||||
|
||||
/// Test ambient tools: end_ambient_cycle via mock agent
|
||||
#[tokio::test]
|
||||
async fn test_ambient_end_cycle_tool() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// Mock: agent calls end_ambient_cycle tool
|
||||
let tool_input = serde_json::json!({
|
||||
"summary": "Merged 2 duplicate memories, pruned 1 stale memory",
|
||||
"memories_modified": 3,
|
||||
"compactions": 0
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "tool_001".to_string(),
|
||||
name: "end_ambient_cycle".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(tool_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// After tool execution, the agent calls the provider again — mock a final response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Cycle complete.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Begin ambient cycle").await?;
|
||||
assert_eq!(response, "Cycle complete.");
|
||||
|
||||
// The tool should have stored a cycle result
|
||||
let result = jcode::tool::ambient::take_cycle_result();
|
||||
assert!(result.is_some());
|
||||
let result = result.unwrap();
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"Merged 2 duplicate memories, pruned 1 stale memory"
|
||||
);
|
||||
assert_eq!(result.memories_modified, 3);
|
||||
assert_eq!(result.compactions, 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test ambient tools: request_permission via mock agent
|
||||
#[tokio::test]
|
||||
async fn test_ambient_request_permission_tool() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
let tool_input = serde_json::json!({
|
||||
"action": "create_pull_request",
|
||||
"description": "Create PR for test fixes",
|
||||
"rationale": "Found 3 failing tests in auth module",
|
||||
"urgency": "high",
|
||||
"wait": false
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "tool_perm_001".to_string(),
|
||||
name: "request_permission".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(tool_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// After tool execution, mock a final response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Permission requested.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
let ambient_session_id = agent.session_id().to_string();
|
||||
jcode::tool::ambient::register_ambient_session(ambient_session_id.clone());
|
||||
|
||||
let response = agent.run_once_capture("Request permission").await?;
|
||||
jcode::tool::ambient::unregister_ambient_session(&ambient_session_id);
|
||||
assert_eq!(response, "Permission requested.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test ambient tools: schedule_ambient via mock agent
|
||||
#[tokio::test]
|
||||
async fn test_ambient_schedule_tool() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
let tool_input = serde_json::json!({
|
||||
"wake_in_minutes": 30,
|
||||
"context": "Check CI results and verify test fixes",
|
||||
"priority": "normal"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "tool_sched_001".to_string(),
|
||||
name: "schedule_ambient".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(tool_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// After tool execution, mock a final response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Scheduled next cycle.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Schedule next cycle").await?;
|
||||
assert_eq!(response, "Scheduled next cycle.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test ambient system prompt builder
|
||||
#[test]
|
||||
fn test_ambient_system_prompt_builder() {
|
||||
use jcode::ambient::{
|
||||
AmbientState, MemoryGraphHealth, ResourceBudget, build_ambient_system_prompt,
|
||||
};
|
||||
|
||||
let state = AmbientState::default();
|
||||
let queue_items = vec![];
|
||||
let health = MemoryGraphHealth {
|
||||
total: 42,
|
||||
active: 38,
|
||||
inactive: 4,
|
||||
low_confidence: 2,
|
||||
contradictions: 1,
|
||||
missing_embeddings: 0,
|
||||
duplicate_candidates: 3,
|
||||
last_consolidation: None,
|
||||
};
|
||||
let recent_sessions = vec![];
|
||||
let feedback: Vec<String> = vec![];
|
||||
let budget = ResourceBudget {
|
||||
provider: "mock".to_string(),
|
||||
tokens_remaining_desc: "50k tokens".to_string(),
|
||||
window_resets_desc: "2h".to_string(),
|
||||
user_usage_rate_desc: "5k/min".to_string(),
|
||||
cycle_budget_desc: "stay under 50k".to_string(),
|
||||
};
|
||||
|
||||
let prompt = build_ambient_system_prompt(
|
||||
&state,
|
||||
&queue_items,
|
||||
&health,
|
||||
&recent_sessions,
|
||||
&feedback,
|
||||
&budget,
|
||||
0,
|
||||
);
|
||||
|
||||
// Verify key sections exist
|
||||
assert!(
|
||||
prompt.contains("ambient agent"),
|
||||
"Prompt missing 'ambient agent'"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Memory Graph Health"),
|
||||
"Prompt missing 'Memory Graph Health'"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Total memories: 42"),
|
||||
"Prompt missing memory count"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Resource Budget"),
|
||||
"Prompt missing 'Resource Budget'"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("end_ambient_cycle"),
|
||||
"Prompt missing end_ambient_cycle instruction"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: status_json
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_status() {
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
let status_json = handle.status_json().await;
|
||||
let status: serde_json::Value = serde_json::from_str(&status_json).unwrap();
|
||||
|
||||
// Verify expected fields exist and have correct types
|
||||
assert!(status.get("status").is_some(), "Missing 'status' field");
|
||||
assert!(
|
||||
status.get("total_cycles").is_some(),
|
||||
"Missing 'total_cycles' field"
|
||||
);
|
||||
assert!(
|
||||
status.get("loop_running").is_some(),
|
||||
"Missing 'loop_running' field"
|
||||
);
|
||||
assert_eq!(
|
||||
status["loop_running"], false,
|
||||
"Runner loop should not be running"
|
||||
);
|
||||
assert!(
|
||||
status["total_cycles"].is_number(),
|
||||
"total_cycles should be a number"
|
||||
);
|
||||
assert!(
|
||||
status.get("queue_count").is_some(),
|
||||
"Missing 'queue_count' field"
|
||||
);
|
||||
assert!(
|
||||
status.get("active_user_sessions").is_some(),
|
||||
"Missing 'active_user_sessions' field"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: trigger and stop
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_trigger_and_stop() {
|
||||
use jcode::ambient::AmbientStatus;
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
// Stop (sets status to disabled)
|
||||
handle.stop().await;
|
||||
let state = handle.state().await;
|
||||
assert!(
|
||||
matches!(state.status, AmbientStatus::Disabled),
|
||||
"After stop(), status should be Disabled, got: {:?}",
|
||||
state.status
|
||||
);
|
||||
|
||||
// Runner should not be running (no loop was started)
|
||||
assert!(!handle.is_running().await, "Runner should not be active");
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: queue_json
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_queue_json() {
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
let json = handle.queue_json().await;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: log_json
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_log_json() {
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
let json = handle.log_json().await;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
}
|
||||
|
||||
/// Test memory reinforcement provenance
|
||||
#[test]
|
||||
fn test_memory_reinforcement_provenance() {
|
||||
use jcode::memory::{MemoryCategory, MemoryEntry};
|
||||
|
||||
let mut entry = MemoryEntry::new(MemoryCategory::Preference, "User prefers dark mode");
|
||||
assert!(entry.reinforcements.is_empty());
|
||||
assert_eq!(entry.strength, 1); // Initial strength
|
||||
|
||||
// Reinforce with provenance
|
||||
entry.reinforce("session_abc123", 42);
|
||||
assert_eq!(entry.strength, 2);
|
||||
assert_eq!(entry.reinforcements.len(), 1);
|
||||
assert_eq!(entry.reinforcements[0].session_id, "session_abc123");
|
||||
assert_eq!(entry.reinforcements[0].message_index, 42);
|
||||
|
||||
// Reinforce again from different session
|
||||
entry.reinforce("session_def456", 10);
|
||||
assert_eq!(entry.strength, 3);
|
||||
assert_eq!(entry.reinforcements.len(), 2);
|
||||
assert_eq!(entry.reinforcements[1].session_id, "session_def456");
|
||||
assert_eq!(entry.reinforcements[1].message_index, 10);
|
||||
}
|
||||
|
||||
/// Test ambient config defaults
|
||||
#[test]
|
||||
fn test_ambient_config_defaults() {
|
||||
use jcode::config::AmbientConfig;
|
||||
|
||||
let config = AmbientConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.allow_api_keys);
|
||||
assert_eq!(config.min_interval_minutes, 5);
|
||||
assert_eq!(config.max_interval_minutes, 120);
|
||||
assert!(config.pause_on_active_session);
|
||||
assert!(config.proactive_work);
|
||||
assert_eq!(config.work_branch_prefix, "ambient/");
|
||||
assert!(config.provider.is_none());
|
||||
assert!(config.model.is_none());
|
||||
assert!(config.api_daily_budget.is_none());
|
||||
}
|
||||
|
||||
/// Test ambient lock acquisition and release
|
||||
#[test]
|
||||
fn test_ambient_lock() {
|
||||
use jcode::ambient::AmbientLock;
|
||||
let _env = setup_test_env().expect("failed to setup isolated JCODE_HOME");
|
||||
|
||||
// First acquisition should succeed
|
||||
let lock1 = AmbientLock::try_acquire();
|
||||
assert!(lock1.is_ok());
|
||||
let lock1 = lock1.unwrap();
|
||||
assert!(lock1.is_some());
|
||||
let lock1 = lock1.unwrap();
|
||||
|
||||
// Second acquisition should fail (lock held)
|
||||
let lock2 = AmbientLock::try_acquire();
|
||||
assert!(lock2.is_ok());
|
||||
assert!(lock2.unwrap().is_none());
|
||||
|
||||
// Release
|
||||
let _ = lock1.release();
|
||||
|
||||
// Now should succeed again
|
||||
let lock3 = AmbientLock::try_acquire();
|
||||
assert!(lock3.is_ok());
|
||||
assert!(lock3.unwrap().is_some());
|
||||
}
|
||||
|
||||
/// Test full ambient cycle simulation with mock provider
|
||||
/// Simulates: agent receives prompt → uses tools → calls end_ambient_cycle
|
||||
#[tokio::test]
|
||||
async fn test_full_ambient_cycle_simulation() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// Turn 1: Agent calls end_ambient_cycle with full data
|
||||
let end_cycle_input = serde_json::json!({
|
||||
"summary": "Gardened memory graph: merged 2 duplicates about dark mode preference, pruned 1 stale memory with confidence 0.02, verified 5 facts against codebase.",
|
||||
"memories_modified": 6,
|
||||
"compactions": 1,
|
||||
"proactive_work": null,
|
||||
"next_schedule": {
|
||||
"wake_in_minutes": 45,
|
||||
"context": "Follow up on memory verification",
|
||||
"priority": "normal"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Starting ambient cycle...\n".to_string()),
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "call_end".to_string(),
|
||||
name: "end_ambient_cycle".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(end_cycle_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// Turn 2: After end_ambient_cycle tool result, agent responds
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Ambient cycle completed successfully.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider.clone(), registry);
|
||||
agent.set_system_prompt("You are the jcode ambient maintenance agent.");
|
||||
|
||||
let response = agent.run_once_capture("Begin your ambient cycle.").await?;
|
||||
|
||||
assert!(response.contains("Ambient cycle completed"));
|
||||
|
||||
// Verify end_ambient_cycle stored the result
|
||||
let result = jcode::tool::ambient::take_cycle_result();
|
||||
assert!(result.is_some());
|
||||
let result = result.unwrap();
|
||||
assert_eq!(result.memories_modified, 6);
|
||||
assert_eq!(result.compactions, 1);
|
||||
assert!(result.summary.contains("Gardened memory graph"));
|
||||
assert!(result.next_schedule.is_some());
|
||||
let sched = result.next_schedule.unwrap();
|
||||
assert_eq!(sched.wake_in_minutes, Some(45));
|
||||
assert!(sched.context.contains("Follow up"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
// ============================================================================
|
||||
// Binary Integration Tests
|
||||
// These tests run the actual jcode binary and require real credentials.
|
||||
// Run with: cargo test --test e2e binary_integration -- --ignored
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Reload/handoff robustness coverage map (for future contributors)
|
||||
//
|
||||
// Unit-level (no credentials, run by default):
|
||||
// - server::reload_state::tests + server::socket_tests: marker/handoff state
|
||||
// machine (Ready/Waiting/Failed/Idle verdicts, dead-pid crash detection,
|
||||
// stale/foreign/completed marker cleanup, Failed-marker preservation,
|
||||
// corrupt-marker tolerance, bounded handoff-event wait).
|
||||
// - server::reload::reload_tests: graceful shutdown signaling, timeout, and
|
||||
// partial-checkpoint behavior; recovery-intent persistence for peers.
|
||||
// - server::reload_recovery::tests: recovery-store path-traversal safety,
|
||||
// persist/peek roundtrip, non-consuming directive peek, delivery
|
||||
// idempotency + continuation mismatch.
|
||||
// - server::util::reload_target_tests: no-downgrade exec-target guard.
|
||||
//
|
||||
// E2E (real spawned process, run with --ignored; need a release binary):
|
||||
// - binary_integration_reload_handoff: server identity changes, marker clears.
|
||||
// - binary_integration_selfdev_reload_reconnects_quickly: repeated reloads.
|
||||
// - binary_integration_selfdev_client_reload_resumes_session.
|
||||
// - binary_integration_selfdev_full_reload_resumes_session_quickly.
|
||||
//
|
||||
// Known E2E gaps worth adding when a release binary is available:
|
||||
// - Concurrent/rapid `client.reload()` calls collapsing into one handoff
|
||||
// without stranding the client or leaving a stuck marker.
|
||||
// - A pre-existing *foreign* stale reload marker (different pid) in the
|
||||
// runtime dir at boot being cleared rather than blocking startup.
|
||||
// - Crash-during-boot of the replacement server (e.g. point the reload
|
||||
// candidate at a binary that exits non-zero) resolving the waiting client
|
||||
// to a Failed verdict instead of an indefinite hang.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Test that the jcode binary can run independent with Claude provider
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires Claude credentials
|
||||
async fn binary_integration_independent_claude() -> Result<()> {
|
||||
use std::process::Command;
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let output = Command::new("cargo")
|
||||
.args([
|
||||
"run",
|
||||
"--release",
|
||||
"--bin",
|
||||
"jcode",
|
||||
"--",
|
||||
"run",
|
||||
"Say 'test-ok' and nothing else",
|
||||
])
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
output.status.success() || stdout.contains("test") || stderr.contains("Claude"),
|
||||
"Binary should run successfully. stdout: {}, stderr: {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that the jcode binary can run with OpenAI provider
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires OpenAI/Codex credentials
|
||||
async fn binary_integration_openai_provider() -> Result<()> {
|
||||
use std::process::Command;
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let output = Command::new("cargo")
|
||||
.args([
|
||||
"run",
|
||||
"--release",
|
||||
"--bin",
|
||||
"jcode",
|
||||
"--",
|
||||
"--provider",
|
||||
"openai",
|
||||
"run",
|
||||
"Say 'openai-ok' and nothing else",
|
||||
])
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Check either success or identifiable OpenAI response
|
||||
let has_response = stdout.to_lowercase().contains("openai")
|
||||
|| stdout.to_lowercase().contains("ok")
|
||||
|| stderr.contains("OpenAI");
|
||||
|
||||
assert!(
|
||||
output.status.success() || has_response,
|
||||
"OpenAI provider should work. stdout: {}, stderr: {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that jcode version command works
|
||||
#[tokio::test]
|
||||
async fn binary_version_command() -> Result<()> {
|
||||
use std::process::Command;
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_jcode"))
|
||||
.arg("--version")
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
assert!(output.status.success(), "Version command should succeed");
|
||||
assert!(
|
||||
stdout.contains("jcode") || stdout.contains("20"),
|
||||
"Version should contain 'jcode' or date. Got: {}",
|
||||
stdout
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test full server reload handoff against a real spawned server process.
|
||||
///
|
||||
/// Requires a built release binary at target/release/jcode because the reload
|
||||
/// flow execs into the repo's reload candidate.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_reload_handoff() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
let stderr_path = temp_root.path().join("server-stderr.log");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let stderr_file = std::fs::File::create(&stderr_path)?;
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_jcode"))
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&socket_path)
|
||||
.arg("serve")
|
||||
// This test must exercise the real exec-based reload handoff, not the
|
||||
// in-process test shortcut used by other e2e cases.
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir)
|
||||
.env("JCODE_DEBUG_CONTROL", "1")
|
||||
.env("JCODE_TEMP_SERVER", "1")
|
||||
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.spawn()?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id before reload"))?
|
||||
.to_string();
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
client.reload().await?;
|
||||
|
||||
let disconnect_deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut saw_disconnect = false;
|
||||
while Instant::now() < disconnect_deadline {
|
||||
match tokio::time::timeout(Duration::from_secs(1), client.read_event()).await {
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
saw_disconnect = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_disconnect,
|
||||
"old client connection never disconnected during reload"
|
||||
);
|
||||
|
||||
let marker_deadline = Instant::now() + Duration::from_secs(20);
|
||||
while jcode::server::reload_marker_active(Duration::from_secs(30)) {
|
||||
if Instant::now() >= marker_deadline {
|
||||
anyhow::bail!("reload marker remained active too long after restart");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let _client = wait_for_server_client(&socket_path).await?;
|
||||
|
||||
let server_info_after =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_after_json: serde_json::Value = serde_json::from_str(&server_info_after)?;
|
||||
let server_id_after = server_info_after_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id after reload"))?;
|
||||
|
||||
assert_ne!(
|
||||
server_id_after, server_id_before,
|
||||
"server identity should change after exec-based reload"
|
||||
);
|
||||
assert!(
|
||||
server_info_after_json
|
||||
.get("uptime_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some(),
|
||||
"replacement server should answer debug state queries after reload"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
kill_child(&mut child);
|
||||
if let Err(ref error) = test_result {
|
||||
if let Ok(stderr) = std::fs::read_to_string(&stderr_path) {
|
||||
eprintln!("spawned server stderr:\n{}", stderr);
|
||||
}
|
||||
eprintln!("reload e2e test error: {error:#}");
|
||||
}
|
||||
test_result
|
||||
}
|
||||
|
||||
/// Test repeated self-dev reload handoff against a real TUI client running in a PTY.
|
||||
///
|
||||
/// Requires a built release binary at target/release/jcode because the
|
||||
/// self-dev server reload path execs into the repo's reload candidate.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_selfdev_reload_reconnects_quickly() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-selfdev-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
|
||||
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
|
||||
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
let mut command = Command::new(&release_binary);
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--provider")
|
||||
.arg("antigravity")
|
||||
.arg("self-dev")
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let mut child = spawn_pty_child(command)?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
|
||||
|
||||
let state_before =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", None).await?;
|
||||
let _: serde_json::Value = serde_json::from_str(&state_before)?;
|
||||
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let mut server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing initial server id"))?
|
||||
.to_string();
|
||||
|
||||
for cycle in 1..=3 {
|
||||
child.send_command("/server-reload")?;
|
||||
|
||||
let server_id_after = wait_for_selfdev_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&server_id_before,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
assert_ne!(
|
||||
server_id_after, server_id_before,
|
||||
"self-dev reload cycle {} should replace the server process",
|
||||
cycle
|
||||
);
|
||||
server_id_before = server_id_after;
|
||||
}
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
debug_run_command(debug_socket_path.clone(), "client:quit", None),
|
||||
)
|
||||
.await;
|
||||
kill_child(&mut child.child);
|
||||
|
||||
if let Err(ref error) = test_result {
|
||||
eprintln!("self-dev reload e2e test error: {error:#}");
|
||||
eprintln!("self-dev client PTY output:\n{}", child.output_text());
|
||||
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
|
||||
eprintln!("self-dev reload logs (tail):\n{}", log_excerpt);
|
||||
}
|
||||
}
|
||||
|
||||
test_result
|
||||
}
|
||||
|
||||
/// Test self-dev client binary reload against a real TUI client running in a PTY.
|
||||
///
|
||||
/// Starts from the test binary, then forces `/client-reload` to re-exec into
|
||||
/// the built release candidate while keeping the shared server online.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_selfdev_client_reload_resumes_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-selfdev-client-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
|
||||
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
|
||||
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
let starter_binary = temp_root.path().join("jcode-selfdev-client-starter");
|
||||
std::fs::copy(env!("CARGO_BIN_EXE_jcode"), &starter_binary)?;
|
||||
let starter_mtime = std::fs::metadata(&release_binary)?
|
||||
.modified()?
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.unwrap_or(std::time::UNIX_EPOCH + Duration::from_secs(1));
|
||||
set_file_mtime(&starter_binary, starter_mtime)?;
|
||||
|
||||
let mut command = Command::new(&starter_binary);
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--provider")
|
||||
.arg("antigravity")
|
||||
.arg("self-dev")
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let mut child = spawn_pty_child(command)?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
|
||||
|
||||
let state_before =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_before_json: serde_json::Value = serde_json::from_str(&state_before)?;
|
||||
let version_before = state_before_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version before reload"))?
|
||||
.to_string();
|
||||
|
||||
let clients_before =
|
||||
debug_run_command(debug_socket_path.clone(), "clients:map", None).await?;
|
||||
let clients_before_json: serde_json::Value = serde_json::from_str(&clients_before)?;
|
||||
let client_id_before = clients_before_json
|
||||
.get("clients")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|clients| {
|
||||
clients.iter().find_map(|client| {
|
||||
let session = client.get("session_id").and_then(|v| v.as_str())?;
|
||||
if session != session_id {
|
||||
return None;
|
||||
}
|
||||
client
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client id before reload"))?;
|
||||
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id before client reload"))?
|
||||
.to_string();
|
||||
|
||||
child.send_command("/client-reload")?;
|
||||
|
||||
let client_id_after = wait_for_selfdev_client_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&client_id_before,
|
||||
&server_id_before,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let state_after =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_after_json: serde_json::Value = serde_json::from_str(&state_after)?;
|
||||
let version_after = state_after_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version after reload"))?;
|
||||
|
||||
assert_ne!(
|
||||
client_id_after, client_id_before,
|
||||
"client reload should reconnect with a different client id"
|
||||
);
|
||||
assert_ne!(
|
||||
version_after, version_before,
|
||||
"client reload should switch binaries"
|
||||
);
|
||||
|
||||
let server_info_after =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_after_json: serde_json::Value = serde_json::from_str(&server_info_after)?;
|
||||
let server_id_after = server_info_after_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id after client reload"))?;
|
||||
assert_eq!(
|
||||
server_id_after, server_id_before,
|
||||
"client reload should not replace the server process"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
debug_run_command(debug_socket_path.clone(), "client:quit", None),
|
||||
)
|
||||
.await;
|
||||
kill_child(&mut child.child);
|
||||
|
||||
if let Err(ref error) = test_result {
|
||||
eprintln!("self-dev client reload e2e test error: {error:#}");
|
||||
eprintln!("self-dev client PTY output:\n{}", child.output_text());
|
||||
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
|
||||
eprintln!("self-dev client reload logs (tail):\n{}", log_excerpt);
|
||||
}
|
||||
}
|
||||
|
||||
test_result
|
||||
}
|
||||
|
||||
/// Test full self-dev `/reload` against a real TUI client running in a PTY.
|
||||
///
|
||||
/// Starts from an older starter binary so the client reloads into the built
|
||||
/// release candidate while the shared server also restarts.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_selfdev_full_reload_resumes_session_quickly() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-selfdev-full-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
|
||||
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
|
||||
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
let starter_binary = temp_root.path().join("jcode-selfdev-full-reload-starter");
|
||||
std::fs::copy(env!("CARGO_BIN_EXE_jcode"), &starter_binary)?;
|
||||
let starter_mtime = std::fs::metadata(&release_binary)?
|
||||
.modified()?
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.unwrap_or(std::time::UNIX_EPOCH + Duration::from_secs(1));
|
||||
set_file_mtime(&starter_binary, starter_mtime)?;
|
||||
|
||||
let mut command = Command::new(&starter_binary);
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--provider")
|
||||
.arg("antigravity")
|
||||
.arg("self-dev")
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let mut child = spawn_pty_child(command)?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
|
||||
|
||||
let state_before =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_before_json: serde_json::Value = serde_json::from_str(&state_before)?;
|
||||
let version_before = state_before_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version before full reload"))?
|
||||
.to_string();
|
||||
|
||||
let clients_before =
|
||||
debug_run_command(debug_socket_path.clone(), "clients:map", None).await?;
|
||||
let clients_before_json: serde_json::Value = serde_json::from_str(&clients_before)?;
|
||||
let client_id_before = clients_before_json
|
||||
.get("clients")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|clients| {
|
||||
clients.iter().find_map(|client| {
|
||||
let session = client.get("session_id").and_then(|v| v.as_str())?;
|
||||
if session != session_id {
|
||||
return None;
|
||||
}
|
||||
client
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client id before full reload"))?;
|
||||
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id before full reload"))?
|
||||
.to_string();
|
||||
|
||||
child.send_command("/reload")?;
|
||||
|
||||
let server_id_after = wait_for_selfdev_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&server_id_before,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client_id_after = wait_for_selfdev_client_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&client_id_before,
|
||||
&server_id_after,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let state_after =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_after_json: serde_json::Value = serde_json::from_str(&state_after)?;
|
||||
let version_after = state_after_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version after full reload"))?;
|
||||
|
||||
assert_ne!(
|
||||
server_id_after, server_id_before,
|
||||
"full reload should replace the server process"
|
||||
);
|
||||
assert_ne!(
|
||||
client_id_after, client_id_before,
|
||||
"full reload should reconnect with a different client id"
|
||||
);
|
||||
assert_ne!(
|
||||
version_after, version_before,
|
||||
"full reload should switch binaries"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
debug_run_command(debug_socket_path.clone(), "client:quit", None),
|
||||
)
|
||||
.await;
|
||||
kill_child(&mut child.child);
|
||||
|
||||
if let Err(ref error) = test_result {
|
||||
eprintln!("self-dev full reload e2e test error: {error:#}");
|
||||
eprintln!("self-dev client PTY output:\n{}", child.output_text());
|
||||
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
|
||||
eprintln!("self-dev full reload logs (tail):\n{}", log_excerpt);
|
||||
}
|
||||
}
|
||||
|
||||
test_result
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
use crate::test_support::*;
|
||||
use futures::future::join_all;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BurstAttachClientMetrics {
|
||||
target_session_id: String,
|
||||
returned_session_id: String,
|
||||
attach_ms: u128,
|
||||
history_message_count: usize,
|
||||
provider_model: Option<String>,
|
||||
event_count: usize,
|
||||
ack_count: usize,
|
||||
history_count: usize,
|
||||
done_count: usize,
|
||||
other_count: usize,
|
||||
}
|
||||
|
||||
enum BurstAttachOutcome {
|
||||
Attached(BurstAttachClientMetrics),
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
async fn burst_attach_resumed_client(
|
||||
socket_path: PathBuf,
|
||||
target_session_id: String,
|
||||
) -> Result<(server::Client, BurstAttachClientMetrics)> {
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_start = Instant::now();
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(None, None, Some(target_session_id.clone()), false, false)
|
||||
.await?;
|
||||
|
||||
let mut event_count = 0usize;
|
||||
let mut ack_count = 0usize;
|
||||
let mut history_count = 0usize;
|
||||
let mut done_count = 0usize;
|
||||
let mut other_count = 0usize;
|
||||
let mut returned_session_id = None;
|
||||
let mut history_message_count = 0usize;
|
||||
let mut provider_model = None;
|
||||
|
||||
// Hosted CI runners intermittently stall a single socket read past 1s when
|
||||
// many burst clients contend for the runtime. Tolerate per-read timeouts and
|
||||
// only bound the total attach wait, mirroring the deflake applied elsewhere.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let event = match timeout(Duration::from_secs(1), client.read_event()).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => continue,
|
||||
};
|
||||
event_count += 1;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => ack_count += 1,
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
messages,
|
||||
provider_model: event_provider_model,
|
||||
..
|
||||
} if id == subscribe_id => {
|
||||
history_count += 1;
|
||||
returned_session_id = Some(session_id);
|
||||
history_message_count = messages.len();
|
||||
provider_model = event_provider_model;
|
||||
}
|
||||
ServerEvent::Done { id } if id == subscribe_id => {
|
||||
done_count += 1;
|
||||
let metrics = BurstAttachClientMetrics {
|
||||
target_session_id,
|
||||
returned_session_id: returned_session_id
|
||||
.ok_or_else(|| anyhow::anyhow!("missing subscribe history event"))?,
|
||||
attach_ms: subscribe_start.elapsed().as_millis(),
|
||||
history_message_count,
|
||||
provider_model,
|
||||
event_count,
|
||||
ack_count,
|
||||
history_count,
|
||||
done_count,
|
||||
other_count,
|
||||
};
|
||||
return Ok((client, metrics));
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == subscribe_id => {
|
||||
anyhow::bail!("subscribe failed for {}: {}", target_session_id, message);
|
||||
}
|
||||
_ => other_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"timed out attaching resumed client to {} after {} events",
|
||||
target_session_id,
|
||||
event_count
|
||||
)
|
||||
}
|
||||
|
||||
async fn burst_attach_resumed_client_with_options(
|
||||
socket_path: PathBuf,
|
||||
target_session_id: String,
|
||||
client_has_local_history: bool,
|
||||
allow_session_takeover: bool,
|
||||
) -> Result<BurstAttachOutcome> {
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_start = Instant::now();
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(
|
||||
None,
|
||||
None,
|
||||
Some(target_session_id.clone()),
|
||||
client_has_local_history,
|
||||
allow_session_takeover,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut event_count = 0usize;
|
||||
let mut ack_count = 0usize;
|
||||
let mut history_count = 0usize;
|
||||
let mut done_count = 0usize;
|
||||
let mut other_count = 0usize;
|
||||
let mut returned_session_id = None;
|
||||
let mut history_message_count = 0usize;
|
||||
let mut provider_model = None;
|
||||
|
||||
// Hosted CI runners intermittently stall a single socket read past 1s when
|
||||
// many burst clients contend for the runtime. Tolerate per-read timeouts and
|
||||
// only bound the total attach wait, mirroring the deflake applied elsewhere.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let event = match timeout(Duration::from_secs(1), client.read_event()).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => continue,
|
||||
};
|
||||
event_count += 1;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => ack_count += 1,
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
messages,
|
||||
provider_model: event_provider_model,
|
||||
..
|
||||
} if id == subscribe_id => {
|
||||
history_count += 1;
|
||||
returned_session_id = Some(session_id);
|
||||
history_message_count = messages.len();
|
||||
provider_model = event_provider_model;
|
||||
}
|
||||
ServerEvent::Done { id } if id == subscribe_id => {
|
||||
done_count += 1;
|
||||
let metrics = BurstAttachClientMetrics {
|
||||
target_session_id,
|
||||
returned_session_id: returned_session_id
|
||||
.ok_or_else(|| anyhow::anyhow!("missing subscribe history event"))?,
|
||||
attach_ms: subscribe_start.elapsed().as_millis(),
|
||||
history_message_count,
|
||||
provider_model,
|
||||
event_count,
|
||||
ack_count,
|
||||
history_count,
|
||||
done_count,
|
||||
other_count,
|
||||
};
|
||||
drop(client);
|
||||
return Ok(BurstAttachOutcome::Attached(metrics));
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == subscribe_id => {
|
||||
return Ok(BurstAttachOutcome::Rejected(message));
|
||||
}
|
||||
_ => other_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"timed out attaching resumed client to {} after {} events",
|
||||
target_session_id,
|
||||
event_count
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_burst_resume_attach_stress(burst_size: usize) -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-burst-spawn-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let unique_suffix = runtime_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("burst");
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let mut expected_session_ids = Vec::with_capacity(burst_size);
|
||||
for idx in 0..burst_size {
|
||||
let mut session = Session::create_with_id(
|
||||
format!("session_burst_attach_{idx}_{unique_suffix}"),
|
||||
None,
|
||||
Some(format!("Burst Attach {idx}")),
|
||||
);
|
||||
session.model = Some("burst-model".to_string());
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("resume me {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("attached reply {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
expected_session_ids.push(session.id);
|
||||
}
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["burst-model"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let cpu_start = current_process_cpu_time()?;
|
||||
let wall_start = Instant::now();
|
||||
|
||||
let burst_results = join_all(expected_session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move { burst_attach_resumed_client(socket_path, session_id.to_string()).await }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut connected_clients = Vec::with_capacity(burst_size);
|
||||
let mut metrics = Vec::with_capacity(burst_size);
|
||||
for result in burst_results {
|
||||
let (client, client_metrics) = result?;
|
||||
assert_eq!(
|
||||
client_metrics.returned_session_id,
|
||||
client_metrics.target_session_id
|
||||
);
|
||||
assert_eq!(client_metrics.history_count, 1);
|
||||
assert_eq!(client_metrics.done_count, 1);
|
||||
assert!(
|
||||
client_metrics.history_message_count >= 2,
|
||||
"expected resumed history for {} to include persisted messages",
|
||||
client_metrics.target_session_id
|
||||
);
|
||||
assert_eq!(
|
||||
client_metrics.provider_model.as_deref(),
|
||||
Some("burst-model")
|
||||
);
|
||||
connected_clients.push(client);
|
||||
metrics.push(client_metrics);
|
||||
}
|
||||
|
||||
let wall_elapsed = wall_start.elapsed();
|
||||
let cpu_elapsed = current_process_cpu_time()?.saturating_sub(cpu_start);
|
||||
|
||||
let client_map = debug_run_command_json(debug_socket_path.clone(), "clients:map", None).await?;
|
||||
let info = debug_run_command_json(debug_socket_path.clone(), "server:info", None).await?;
|
||||
|
||||
let clients = client_map
|
||||
.get("clients")
|
||||
.and_then(|value| value.as_array())
|
||||
.context("clients:map missing clients array")?;
|
||||
assert_eq!(
|
||||
client_map.get("count").and_then(|value| value.as_u64()),
|
||||
Some(burst_size as u64)
|
||||
);
|
||||
assert_eq!(clients.len(), burst_size);
|
||||
|
||||
let mapped_session_ids: HashSet<String> = clients
|
||||
.iter()
|
||||
.filter_map(|client| {
|
||||
client
|
||||
.get("session_id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.collect();
|
||||
let expected_session_ids_set: HashSet<String> = expected_session_ids.iter().cloned().collect();
|
||||
assert_eq!(mapped_session_ids, expected_session_ids_set);
|
||||
|
||||
let ready_count = clients
|
||||
.iter()
|
||||
.filter(|client| client.get("status").and_then(|value| value.as_str()) == Some("ready"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
ready_count, burst_size,
|
||||
"all resumed clients should settle to ready"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
info.get("session_count").and_then(|value| value.as_u64()),
|
||||
Some(burst_size as u64)
|
||||
);
|
||||
assert_eq!(
|
||||
info.get("swarm_member_count")
|
||||
.and_then(|value| value.as_u64()),
|
||||
Some(burst_size as u64),
|
||||
"burst attach should not leak temporary swarm members"
|
||||
);
|
||||
|
||||
let mut latencies_ms: Vec<u128> = metrics.iter().map(|metric| metric.attach_ms).collect();
|
||||
latencies_ms.sort_unstable();
|
||||
let total_events: usize = metrics.iter().map(|metric| metric.event_count).sum();
|
||||
let total_acks: usize = metrics.iter().map(|metric| metric.ack_count).sum();
|
||||
let total_histories: usize = metrics.iter().map(|metric| metric.history_count).sum();
|
||||
let total_dones: usize = metrics.iter().map(|metric| metric.done_count).sum();
|
||||
let total_other_events: usize = metrics.iter().map(|metric| metric.other_count).sum();
|
||||
let total_history_messages: usize = metrics
|
||||
.iter()
|
||||
.map(|metric| metric.history_message_count)
|
||||
.sum();
|
||||
let cpu_utilization = if wall_elapsed.is_zero() {
|
||||
0.0
|
||||
} else {
|
||||
cpu_elapsed.as_secs_f64() / wall_elapsed.as_secs_f64()
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"burst_spawn_metrics={} ",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"burst_size": burst_size,
|
||||
"wall_ms": wall_elapsed.as_millis(),
|
||||
"cpu_ms": cpu_elapsed.as_millis(),
|
||||
"cpu_utilization_ratio": cpu_utilization,
|
||||
"cpu_ms_per_attach": cpu_elapsed.as_secs_f64() * 1000.0 / burst_size as f64,
|
||||
"latency_ms": {
|
||||
"min": latencies_ms.first().copied().unwrap_or(0),
|
||||
"p50": percentile_ms(&latencies_ms, 50),
|
||||
"p90": percentile_ms(&latencies_ms, 90),
|
||||
"p99": percentile_ms(&latencies_ms, 99),
|
||||
"max": latencies_ms.last().copied().unwrap_or(0),
|
||||
"spread": latencies_ms.last().copied().unwrap_or(0)
|
||||
.saturating_sub(latencies_ms.first().copied().unwrap_or(0)),
|
||||
},
|
||||
"events": {
|
||||
"total": total_events,
|
||||
"acks": total_acks,
|
||||
"histories": total_histories,
|
||||
"dones": total_dones,
|
||||
"other": total_other_events,
|
||||
},
|
||||
"history_messages_total": total_history_messages,
|
||||
"connected_clients": clients.len(),
|
||||
"unique_session_mappings": mapped_session_ids.len(),
|
||||
"ready_count": ready_count,
|
||||
"server_info": info,
|
||||
}))?
|
||||
);
|
||||
|
||||
drop(connected_clients);
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn burst_retry_takeover_without_local_history_keeps_existing_live_clients_connected()
|
||||
-> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-burst-spawn-live-clients-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let unique_suffix = runtime_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("burst-live");
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let live_session_count = 10usize;
|
||||
let mut live_session_ids = Vec::with_capacity(live_session_count);
|
||||
for idx in 0..live_session_count {
|
||||
let mut session = Session::create_with_id(
|
||||
format!("session_live_attach_{idx}_{unique_suffix}"),
|
||||
None,
|
||||
Some(format!("Live Attach {idx}")),
|
||||
);
|
||||
session.model = Some("burst-model".to_string());
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("resume me live {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("live reply {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
live_session_ids.push(session.id);
|
||||
}
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["burst-model"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut live_clients = Vec::with_capacity(live_session_count);
|
||||
for session_id in live_session_ids.iter().cloned() {
|
||||
let (client, metrics) =
|
||||
burst_attach_resumed_client(socket_path.clone(), session_id.clone()).await?;
|
||||
assert_eq!(metrics.returned_session_id, session_id);
|
||||
live_clients.push((session_id, client));
|
||||
}
|
||||
|
||||
// A client is `Done` (from its own perspective) slightly before the server
|
||||
// finishes registering it in `clients:map`. Wait until all live clients are
|
||||
// registered AND the map is stable across two reads before snapshotting it;
|
||||
// otherwise a retry burst can race a not-yet-settled live registration and
|
||||
// legitimately-but-nondeterministically take it over, flaking the assertion
|
||||
// below. (Was an intermittent CI failure under load.)
|
||||
wait_for_debug_client_count(
|
||||
&debug_socket_path,
|
||||
live_session_count,
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
let initial_session_to_client = {
|
||||
let mut previous: Option<std::collections::HashMap<String, String>> = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let snapshot = client_id_map(
|
||||
&debug_run_command_json(debug_socket_path.clone(), "clients:map", None).await?,
|
||||
)?;
|
||||
if snapshot.len() == live_session_count && previous.as_ref() == Some(&snapshot) {
|
||||
break snapshot;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break snapshot;
|
||||
}
|
||||
previous = Some(snapshot);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
};
|
||||
|
||||
let retry_results = join_all(live_session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move {
|
||||
burst_attach_resumed_client_with_options(
|
||||
socket_path,
|
||||
session_id.to_string(),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
for result in retry_results {
|
||||
match result? {
|
||||
BurstAttachOutcome::Attached(metrics) => {
|
||||
assert_eq!(metrics.returned_session_id, metrics.target_session_id);
|
||||
assert_eq!(metrics.history_count, 1);
|
||||
assert_eq!(metrics.done_count, 1);
|
||||
}
|
||||
BurstAttachOutcome::Rejected(message) => {
|
||||
anyhow::bail!("retry attach should succeed for live shared session: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each retry attaches as a short-lived observer and then drops, so after
|
||||
// the burst the server asynchronously drains those extra connections. The
|
||||
// invariant we actually care about is that none of the *original* live
|
||||
// client connections were superseded. Wait for the connection set to settle
|
||||
// back to exactly the live clients, then compare the set of live client ids.
|
||||
// (Comparing a session->client_id map directly is ill-defined here because
|
||||
// multiple connections can momentarily share a session id and the lossy map
|
||||
// collapses them, which flaked under CI load.)
|
||||
let initial_client_ids: std::collections::BTreeSet<String> =
|
||||
initial_session_to_client.values().cloned().collect();
|
||||
wait_for_debug_client_count(
|
||||
&debug_socket_path,
|
||||
live_session_count,
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await?;
|
||||
let final_client_ids = {
|
||||
let mut previous: Option<std::collections::BTreeSet<String>> = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let snapshot: std::collections::BTreeSet<String> = client_id_map(
|
||||
&debug_run_command_json(debug_socket_path.clone(), "clients:map", None).await?,
|
||||
)?
|
||||
.into_values()
|
||||
.collect();
|
||||
if snapshot.len() == live_session_count && previous.as_ref() == Some(&snapshot) {
|
||||
break snapshot;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break snapshot;
|
||||
}
|
||||
previous = Some(snapshot);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
final_client_ids, initial_client_ids,
|
||||
"existing live client connections should not be replaced during burst retries"
|
||||
);
|
||||
|
||||
drop(live_clients);
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stress the burst attach path used when many spawned windows resume pre-created sessions.
|
||||
/// This targets the race-prone phase directly and records useful metrics for regressions.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn burst_spawn_resume_attach_keeps_unique_live_mappings_and_reports_metrics() -> Result<()> {
|
||||
run_burst_resume_attach_stress(20).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn burst_attach_detach_reattach_restores_live_clients_cleanly() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-burst-spawn-reattach-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let unique_suffix = runtime_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("burst-reattach");
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let burst_size = 6usize;
|
||||
let mut session_ids = Vec::with_capacity(burst_size);
|
||||
for idx in 0..burst_size {
|
||||
let mut session = Session::create_with_id(
|
||||
format!("session_burst_reattach_{idx}_{unique_suffix}"),
|
||||
None,
|
||||
Some(format!("Burst Reattach {idx}")),
|
||||
);
|
||||
session.model = Some("burst-model".to_string());
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("resume me reattach {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("reattach reply {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
session_ids.push(session.id);
|
||||
}
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["burst-model"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let initial_results = join_all(session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move { burst_attach_resumed_client(socket_path, session_id.to_string()).await }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut initial_clients = Vec::with_capacity(burst_size);
|
||||
for result in initial_results {
|
||||
let (client, metrics) = result?;
|
||||
assert_eq!(metrics.returned_session_id, metrics.target_session_id);
|
||||
initial_clients.push(client);
|
||||
}
|
||||
wait_for_debug_client_count(&debug_socket_path, burst_size, Duration::from_secs(5)).await?;
|
||||
|
||||
drop(initial_clients);
|
||||
wait_for_debug_client_count(&debug_socket_path, 0, Duration::from_secs(5)).await?;
|
||||
|
||||
let reattach_results = join_all(session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move { burst_attach_resumed_client(socket_path, session_id.to_string()).await }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut reattached_clients = Vec::with_capacity(burst_size);
|
||||
for result in reattach_results {
|
||||
let (client, metrics) = result?;
|
||||
assert_eq!(metrics.returned_session_id, metrics.target_session_id);
|
||||
assert_eq!(metrics.history_count, 1);
|
||||
assert_eq!(metrics.done_count, 1);
|
||||
reattached_clients.push(client);
|
||||
}
|
||||
wait_for_debug_client_count(&debug_socket_path, burst_size, Duration::from_secs(5)).await?;
|
||||
|
||||
drop(reattached_clients);
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
#[ignore = "resource-heavy scale validation"]
|
||||
async fn burst_spawn_resume_attach_scales_to_100_clients() -> Result<()> {
|
||||
run_burst_resume_attach_stress(100).await
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headed compositor smoke for Alt+Shift+E expand badge.
|
||||
|
||||
Uses kitty remote control to launch a real jcode TUI window, prepares the same
|
||||
fixture, focuses the window, presses Alt+Shift+E with wtype, and checks state.
|
||||
This complements the headless PTY E2E by covering Wayland/compositor mapping.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
SOCKET_PATH = os.path.join(RUNTIME_DIR, "jcode-debug.sock")
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def send_cmd(sock, cmd, session_id=None, timeout=10):
|
||||
req = {"type": "debug_command", "id": int(time.time() * 1000) % 1000000, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
sock.sendall((json.dumps(req) + "\n").encode())
|
||||
sock.settimeout(timeout)
|
||||
data = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
raise RuntimeError("debug socket closed")
|
||||
data += chunk
|
||||
try:
|
||||
return json.loads(data.decode())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
def sh(cmd, **kwargs):
|
||||
return subprocess.check_output(cmd, text=True, **kwargs).strip()
|
||||
|
||||
|
||||
def wait_for(fn, timeout=10, desc="condition"):
|
||||
deadline = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = fn()
|
||||
if last:
|
||||
return last
|
||||
time.sleep(0.15)
|
||||
raise AssertionError(f"timed out waiting for {desc}; last={last!r}")
|
||||
|
||||
|
||||
def find_kitty_socket():
|
||||
for path in sorted(Path("/tmp").glob("kitty.sock-*")):
|
||||
try:
|
||||
out = sh(["kitty", "@", "--to", f"unix:{path}", "ls"], stderr=subprocess.DEVNULL)
|
||||
data = json.loads(out)
|
||||
if data:
|
||||
return str(path)
|
||||
except Exception:
|
||||
continue
|
||||
raise SystemExit("No kitty remote socket found")
|
||||
|
||||
|
||||
def find_niri_window_id(title):
|
||||
try:
|
||||
out = sh(["niri", "msg", "windows"])
|
||||
except Exception:
|
||||
return None
|
||||
current_id = None
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("Window ID "):
|
||||
current_id = line.split()[2].rstrip(":")
|
||||
elif line.startswith('Title: ') and title in line and current_id:
|
||||
return current_id
|
||||
return None
|
||||
|
||||
|
||||
def focused_niri_window_id():
|
||||
try:
|
||||
out = sh(["niri", "msg", "windows"])
|
||||
except Exception:
|
||||
return None
|
||||
current_id = None
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith("Window ID "):
|
||||
current_id = line.split()[2].rstrip(":")
|
||||
if "(focused)" in line:
|
||||
return current_id
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
if not shutil_which("kitty") or not shutil_which("wtype"):
|
||||
raise SystemExit("SKIP: kitty and wtype are required")
|
||||
binary = os.environ.get("JCODE_E2E_BIN", str(REPO / "target" / "selfdev" / "jcode"))
|
||||
kitty_sock = os.environ.get("KITTY_E2E_SOCKET", find_kitty_socket())
|
||||
title = f"JCODE_EXPAND_E2E_{int(time.time())}"
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(SOCKET_PATH)
|
||||
session_id = None
|
||||
window_id = None
|
||||
previous_focused_niri = focused_niri_window_id()
|
||||
try:
|
||||
res = send_cmd(sock, f"create_session:selfdev:{REPO}")
|
||||
if not res.get("ok"):
|
||||
raise AssertionError(res)
|
||||
session_id = json.loads(res["output"])["session_id"]
|
||||
print(f"session={session_id}")
|
||||
|
||||
window_id = sh([
|
||||
"kitty", "@", "--to", f"unix:{kitty_sock}", "launch",
|
||||
"--type", "os-window", "--title", title,
|
||||
binary, "self-dev", "--resume", session_id,
|
||||
])
|
||||
print(f"window={window_id}")
|
||||
|
||||
def attached():
|
||||
out = send_cmd(sock, "clients:map", timeout=5)
|
||||
clients = json.loads(out["output"])["clients"] if isinstance(out.get("output"), str) else out.get("clients", [])
|
||||
return any(c.get("session_id") == session_id for c in clients)
|
||||
wait_for(attached, timeout=15, desc="client attach")
|
||||
|
||||
res = send_cmd(sock, "client:expand-badge-fixture", session_id=session_id)
|
||||
print(f"fixture={res.get('output')}")
|
||||
before = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"before={before}")
|
||||
|
||||
niri_id = find_niri_window_id(title)
|
||||
focused_after_launch = focused_niri_window_id()
|
||||
if niri_id is None and focused_after_launch != previous_focused_niri:
|
||||
niri_id = focused_after_launch
|
||||
if niri_id is None:
|
||||
niri_id = wait_for(lambda: find_niri_window_id(title), timeout=3, desc="niri os-window")
|
||||
print(f"niri_window={niri_id}")
|
||||
subprocess.run(["niri", "msg", "action", "focus-window", "--id", str(niri_id)], check=False)
|
||||
sh(["kitty", "@", "--to", f"unix:{kitty_sock}", "focus-window", "--match", f"id:{window_id}"])
|
||||
time.sleep(0.5)
|
||||
subprocess.check_call(["wtype", "-M", "alt", "-M", "shift", "e", "-m", "shift", "-m", "alt"])
|
||||
time.sleep(0.5)
|
||||
|
||||
after = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"after={after}")
|
||||
if after["diff_mode"] != "FullInline" or after["input"]:
|
||||
raise AssertionError(f"headed wtype Alt+Shift+E failed: {after}")
|
||||
print("PASS: headed wtype Alt+Shift+E expands edit badge")
|
||||
finally:
|
||||
if window_id:
|
||||
subprocess.run(["kitty", "@", "--to", f"unix:{kitty_sock}", "close-window", "--match", f"id:{window_id}"], stderr=subprocess.DEVNULL)
|
||||
if session_id:
|
||||
try:
|
||||
send_cmd(sock, f"destroy_session:{session_id}", timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
|
||||
def shutil_which(cmd):
|
||||
from shutil import which
|
||||
return which(cmd)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless E2E regression for the edit expand badge shortcut.
|
||||
|
||||
This starts a real jcode TUI client inside a pseudo-terminal, prepares a real
|
||||
rendered edit-diff expand-badge fixture through the client debug command, sends
|
||||
terminal key bytes to the PTY, and asserts the live TUI state changed.
|
||||
|
||||
Run from repo root after building jcode:
|
||||
python3 tests/e2e/expand_badge_headless.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
SOCKET_PATH = os.path.join(RUNTIME_DIR, "jcode-debug.sock")
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def send_cmd(sock, cmd, session_id=None, timeout=10):
|
||||
req = {"type": "debug_command", "id": int(time.time() * 1000) % 1000000, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
sock.sendall((json.dumps(req) + "\n").encode())
|
||||
sock.settimeout(timeout)
|
||||
data = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
raise RuntimeError("debug socket closed")
|
||||
data += chunk
|
||||
try:
|
||||
return json.loads(data.decode())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=10, interval=0.1, desc="condition"):
|
||||
deadline = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = predicate()
|
||||
if last:
|
||||
return last
|
||||
time.sleep(interval)
|
||||
raise AssertionError(f"timed out waiting for {desc}; last={last!r}")
|
||||
|
||||
|
||||
def drain_pty(master_fd):
|
||||
chunks = []
|
||||
while True:
|
||||
r, _, _ = select.select([master_fd], [], [], 0)
|
||||
if not r:
|
||||
break
|
||||
try:
|
||||
chunks.append(os.read(master_fd, 65536))
|
||||
except OSError:
|
||||
break
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def main():
|
||||
binary = os.environ.get("JCODE_E2E_BIN")
|
||||
if not binary:
|
||||
candidates = [
|
||||
REPO / "target" / "selfdev" / "jcode",
|
||||
Path.home() / ".jcode" / "builds" / "current" / "jcode",
|
||||
]
|
||||
binary = next((str(p) for p in candidates if p.exists()), None)
|
||||
if not binary:
|
||||
raise SystemExit("No jcode binary found. Set JCODE_E2E_BIN or build first.")
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(SOCKET_PATH)
|
||||
|
||||
session_id = None
|
||||
proc = None
|
||||
master_fd = None
|
||||
try:
|
||||
res = send_cmd(sock, f"create_session:selfdev:{REPO}")
|
||||
if not res.get("ok"):
|
||||
raise AssertionError(f"create_session failed: {res}")
|
||||
session_id = json.loads(res["output"])["session_id"]
|
||||
print(f"session={session_id}")
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
env = os.environ.copy()
|
||||
env.setdefault("TERM", "xterm-kitty")
|
||||
env.setdefault("JCODE_CLIENT_SELFDEV_MODE", "1")
|
||||
proc = subprocess.Popen(
|
||||
[binary, "self-dev", "--resume", session_id],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=str(REPO),
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
def client_attached():
|
||||
out = send_cmd(sock, "clients:map", timeout=5)
|
||||
clients = json.loads(out["output"])["clients"] if isinstance(out.get("output"), str) else out.get("clients", [])
|
||||
return any(c.get("session_id") == session_id for c in clients)
|
||||
|
||||
wait_for(client_attached, timeout=15, desc="TUI client attach")
|
||||
drain_pty(master_fd)
|
||||
|
||||
res = send_cmd(sock, "client:expand-badge-fixture", session_id=session_id)
|
||||
if not res.get("ok"):
|
||||
raise AssertionError(f"fixture failed: {res}")
|
||||
print(f"fixture={res['output']}")
|
||||
time.sleep(0.5)
|
||||
screen = send_cmd(sock, "client:history", session_id=session_id).get("output", "")
|
||||
if "Edited demo.txt" not in screen:
|
||||
raise AssertionError("fixture did not install edit message")
|
||||
|
||||
state = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"before={state}")
|
||||
assert state["diff_mode"] == "Inline", state
|
||||
assert state["input"] == "", state
|
||||
|
||||
# CSI-u for Alt+Shift+e: codepoint 101 ('e'), modifier value 4
|
||||
# (1 + shift bit 1 + alt bit 2). This is the enhanced keyboard
|
||||
# protocol path used by modern terminals and crossterm.
|
||||
os.write(master_fd, b"\x1b[101;4u")
|
||||
time.sleep(0.5)
|
||||
|
||||
after = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"after_csi_u={after}")
|
||||
if after["diff_mode"] != "FullInline" or after["input"]:
|
||||
# Also try the legacy Alt+e encoding so failures print both states.
|
||||
os.write(master_fd, b"\x1be")
|
||||
time.sleep(0.5)
|
||||
after_legacy = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"after_legacy={after_legacy}")
|
||||
raise AssertionError(
|
||||
"Alt+Shift+E did not expand in headless PTY: "
|
||||
f"after_csi_u={after}, after_legacy={after_legacy}"
|
||||
)
|
||||
|
||||
print("PASS: headless PTY Alt+Shift+E expands edit badge without inserting text")
|
||||
return 0
|
||||
finally:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
if master_fd is not None:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
if session_id:
|
||||
try:
|
||||
send_cmd(sock, f"destroy_session:{session_id}", timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
//! End-to-end tests for jcode using a mock provider
|
||||
//!
|
||||
//! These tests verify the full flow from user input to response
|
||||
//! without making actual API calls.
|
||||
|
||||
mod mock_provider;
|
||||
mod test_support;
|
||||
|
||||
mod ambient;
|
||||
mod binary_integration;
|
||||
mod burst_spawn;
|
||||
mod provider_behavior;
|
||||
mod reload_multiclient;
|
||||
mod safety;
|
||||
mod session_flow;
|
||||
mod transport;
|
||||
#[cfg(windows)]
|
||||
mod windows_lifecycle;
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Mock provider for e2e tests
|
||||
//!
|
||||
//! Returns pre-scripted StreamEvent sequences for deterministic testing.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_stream::stream;
|
||||
use jcode::message::{Message, StreamEvent, ToolDefinition};
|
||||
use jcode::provider::{EventStream, Provider};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct MockProvider {
|
||||
responses: Arc<Mutex<VecDeque<Vec<StreamEvent>>>>,
|
||||
models: Vec<&'static str>,
|
||||
current_model: Arc<Mutex<String>>,
|
||||
/// Captured system prompts from complete() calls (for testing)
|
||||
pub captured_system_prompts: Arc<Mutex<Vec<String>>>,
|
||||
/// Captured resume session IDs from complete() calls (for testing)
|
||||
pub captured_resume_session_ids: Arc<Mutex<Vec<Option<String>>>>,
|
||||
/// Captured model names from complete() calls (for testing)
|
||||
pub captured_models: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
responses: Arc::new(Mutex::new(VecDeque::new())),
|
||||
models: Vec::new(),
|
||||
current_model: Arc::new(Mutex::new("mock".to_string())),
|
||||
captured_system_prompts: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_resume_session_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_models: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_models(models: Vec<&'static str>) -> Self {
|
||||
let current = models
|
||||
.first()
|
||||
.map(|m| (*m).to_string())
|
||||
.unwrap_or_else(|| "mock".to_string());
|
||||
Self {
|
||||
responses: Arc::new(Mutex::new(VecDeque::new())),
|
||||
models,
|
||||
current_model: Arc::new(Mutex::new(current)),
|
||||
captured_system_prompts: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_resume_session_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_models: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a response (sequence of StreamEvents) to be returned on next complete() call
|
||||
pub fn queue_response(&self, events: Vec<StreamEvent>) {
|
||||
self.responses.lock().unwrap().push_back(events);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for MockProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
_messages: &[Message],
|
||||
_tools: &[ToolDefinition],
|
||||
system: &str,
|
||||
resume_session_id: Option<&str>,
|
||||
) -> Result<EventStream> {
|
||||
// Capture the system prompt for testing
|
||||
self.captured_system_prompts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(system.to_string());
|
||||
self.captured_resume_session_ids
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(resume_session_id.map(|s| s.to_string()));
|
||||
self.captured_models.lock().unwrap().push(self.model());
|
||||
|
||||
let events = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_default();
|
||||
|
||||
let stream = stream! {
|
||||
for event in events {
|
||||
yield Ok(event);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn model(&self) -> String {
|
||||
self.current_model.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) -> Result<()> {
|
||||
if !self.models.is_empty() && !self.models.contains(&model) {
|
||||
anyhow::bail!("Unknown model: {}", model);
|
||||
}
|
||||
*self.current_model.lock().unwrap() = model.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn available_models(&self) -> Vec<&'static str> {
|
||||
self.models.clone()
|
||||
}
|
||||
|
||||
fn fork(&self) -> Arc<dyn Provider> {
|
||||
let current = self.current_model.lock().unwrap().clone();
|
||||
Arc::new(MockProvider {
|
||||
responses: self.responses.clone(),
|
||||
models: self.models.clone(),
|
||||
current_model: Arc::new(Mutex::new(current)),
|
||||
captured_system_prompts: self.captured_system_prompts.clone(),
|
||||
captured_resume_session_ids: self.captured_resume_session_ids.clone(),
|
||||
captured_models: self.captured_models.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
/// Test that multi-turn conversation works with session resume
|
||||
#[tokio::test]
|
||||
async fn test_multi_turn_conversation() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// First turn response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("I'll remember that.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-abc".to_string()),
|
||||
]);
|
||||
|
||||
// Second turn response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("You said hello earlier.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-abc".to_string()),
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
// First turn
|
||||
let response1 = agent.run_once_capture("Hello").await?;
|
||||
assert_eq!(response1, "I'll remember that.");
|
||||
|
||||
// Second turn - should use session resume
|
||||
let response2 = agent.run_once_capture("What did I say?").await?;
|
||||
assert_eq!(response2, "You said hello earlier.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that token usage is tracked
|
||||
#[tokio::test]
|
||||
async fn test_token_usage() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TokenUsage {
|
||||
input_tokens: Some(10),
|
||||
output_tokens: Some(20),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
},
|
||||
StreamEvent::TextDelta("Response".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-123".to_string()),
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Test").await?;
|
||||
assert_eq!(response, "Response");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test error handling
|
||||
#[tokio::test]
|
||||
async fn test_stream_error() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Starting...".to_string()),
|
||||
StreamEvent::Error {
|
||||
message: "Something went wrong".to_string(),
|
||||
retry_after_secs: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let result = agent.run_once_capture("Test").await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Something went wrong")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test model cycling over the socket interface (server + client)
|
||||
#[tokio::test]
|
||||
async fn test_socket_model_cycle_supported_models() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::with_models(vec!["gpt-5.2-codex", "claude-opus-4-5-20251101"]);
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let request_id = client.cycle_model(1).await?;
|
||||
|
||||
let mut saw_model_changed = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::ModelChanged {
|
||||
id, model, error, ..
|
||||
} if id == request_id => {
|
||||
assert!(error.is_none(), "Expected successful model change");
|
||||
assert_eq!(model, "claude-opus-4-5-20251101");
|
||||
saw_model_changed = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
assert!(saw_model_changed, "Did not receive model_changed event");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that resume restores model selection and tool output in history
|
||||
#[tokio::test]
|
||||
async fn test_resume_restores_model_and_tool_history() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-resume-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
let mut session = Session::create(None, Some("Resume Test".to_string()));
|
||||
session.model = Some("gpt-5.2-codex".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "Run a tool".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
jcode::message::Role::Assistant,
|
||||
vec![
|
||||
jcode::message::ContentBlock::Text {
|
||||
text: "Running...".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
jcode::message::ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "bash".to_string(),
|
||||
input: serde_json::json!({"cmd": "echo hi"}),
|
||||
thought_signature: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "hi\n".to_string(),
|
||||
is_error: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
// Default model = claude, resume should switch to gpt-5.2-codex
|
||||
let provider = MockProvider::with_models(vec!["claude-opus-4-5-20251101", "gpt-5.2-codex"]);
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
|
||||
let mut history_event = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::History {
|
||||
id,
|
||||
messages,
|
||||
provider_model,
|
||||
..
|
||||
} if id == resume_id => {
|
||||
history_event = Some((messages, provider_model));
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
let (messages, provider_model) =
|
||||
history_event.ok_or_else(|| anyhow::anyhow!("Did not receive history event"))?;
|
||||
|
||||
assert_eq!(provider_model, Some("gpt-5.2-codex".to_string()));
|
||||
|
||||
let tool_msg = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "tool")
|
||||
.ok_or_else(|| anyhow::anyhow!("Tool message missing in history"))?;
|
||||
assert!(tool_msg.content.contains("hi"));
|
||||
let tool_data = tool_msg
|
||||
.tool_data
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Tool metadata missing in history"))?;
|
||||
assert_eq!(tool_data.name, "bash");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that subscribe selfdev hint marks the session as canary
|
||||
#[tokio::test]
|
||||
async fn test_resume_session_with_local_history_uses_metadata_only_history() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-target-subscribe-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
let mut session = Session::create(None, Some("Target Subscribe Test".to_string()));
|
||||
session.model = Some("model-a".to_string());
|
||||
session.provider_session_id = Some("provider-resume-123".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "Existing local history".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
jcode::message::Role::Assistant,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "Existing assistant response".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a"]));
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("resumed ok".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
|
||||
let mut saw_done = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Done { id } if id == subscribe_id => {
|
||||
saw_done = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_done, "Did not receive subscribe done event");
|
||||
|
||||
let resume_id = client
|
||||
.resume_session_with_options(&session.id, true, false)
|
||||
.await?;
|
||||
let mut history_checked = false;
|
||||
let mut resume_done = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline && !(history_checked && resume_done) {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
messages,
|
||||
provider_model,
|
||||
..
|
||||
} if id == resume_id => {
|
||||
assert_eq!(session_id, session.id);
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
2,
|
||||
"resume history should include persisted local messages"
|
||||
);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert!(messages[0].content.contains("Existing local history"));
|
||||
assert_eq!(messages[1].role, "assistant");
|
||||
assert!(
|
||||
messages[1].content.contains("Existing assistant response"),
|
||||
"resume history should include persisted assistant response"
|
||||
);
|
||||
assert_eq!(provider_model, Some("model-a".to_string()));
|
||||
history_checked = true;
|
||||
}
|
||||
ServerEvent::Done { id } if id == resume_id => {
|
||||
resume_done = true;
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == resume_id => {
|
||||
anyhow::bail!("resume_session failed: {}", message);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(history_checked, "Did not receive resume history event");
|
||||
assert!(resume_done, "Did not receive resume done event");
|
||||
|
||||
let msg_id = client.send_message("continue resumed session").await?;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut saw_message_done = false;
|
||||
let mut seen_events = Vec::new();
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
seen_events.push(format!("{event:?}"));
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg_id) {
|
||||
saw_message_done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_message_done,
|
||||
"Did not receive Done for resumed message. Seen events: {}\nstate={}\nhistory={}\nlogs={}",
|
||||
seen_events.join(" | "),
|
||||
debug_run_command(debug_socket_path.clone(), "state", Some(&session.id))
|
||||
.await
|
||||
.unwrap_or_else(|err| format!("<state error: {err}>")),
|
||||
debug_run_command(debug_socket_path.clone(), "history", Some(&session.id))
|
||||
.await
|
||||
.unwrap_or_else(|err| format!("<history error: {err}>")),
|
||||
std::env::var_os("JCODE_HOME")
|
||||
.and_then(|home| latest_log_excerpt(std::path::Path::new(&home)))
|
||||
.unwrap_or_else(|| "<no logs>".to_string())
|
||||
);
|
||||
|
||||
let resume_ids = provider.captured_resume_session_ids.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
resume_ids.last().cloned(),
|
||||
Some(Some("provider-resume-123".to_string()))
|
||||
);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// End-to-end: resume_all_sessions continues a live session whose last
|
||||
/// visible turn is an unanswered (interrupted) user message, and reports a
|
||||
/// summary describing the resumed session.
|
||||
#[tokio::test]
|
||||
async fn test_resume_all_sessions_continues_interrupted_live_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-resume-all-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
// A session left with a pending user turn the assistant never answered.
|
||||
let mut session = Session::create(None, Some("Interrupted Session".to_string()));
|
||||
session.model = Some("model-a".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "keep going on the migration".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::with_models(vec!["model-a"]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Continuing the migration now.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
let _ = collect_until_done_unix(&mut client, subscribe_id).await?;
|
||||
|
||||
// Make the interrupted session live by attaching this client to it.
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
let _ = collect_until_done_unix(&mut client, resume_id).await?;
|
||||
|
||||
// Ask the server to continue every interrupted live session.
|
||||
let resume_all_id = client.resume_all_sessions().await?;
|
||||
|
||||
let mut saw_continuation = false;
|
||||
let mut resume_all_result: Option<(usize, usize)> = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline && (!saw_continuation || resume_all_result.is_none()) {
|
||||
let event = tokio::time::timeout(Duration::from_secs(2), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::TextDelta { text } if text.contains("Continuing the migration now.") => {
|
||||
saw_continuation = true;
|
||||
}
|
||||
ServerEvent::ResumeAllResult {
|
||||
id,
|
||||
resumed,
|
||||
skipped,
|
||||
..
|
||||
} if id == resume_all_id => {
|
||||
resume_all_result = Some((resumed, skipped));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
assert!(
|
||||
saw_continuation,
|
||||
"interrupted live session should stream a continuation after resume_all_sessions"
|
||||
);
|
||||
let (resumed, skipped) =
|
||||
resume_all_result.ok_or_else(|| anyhow::anyhow!("did not receive ResumeAllResult"))?;
|
||||
assert_eq!(resumed, 1, "exactly one interrupted session should resume");
|
||||
assert_eq!(skipped, 0, "no live session should be skipped");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resume_session_reports_reload_interruption_for_peer_sessions() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-reload-interruption-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
let mut session = Session::create(None, Some("Reload Interrupted Session".to_string()));
|
||||
session.model = Some("model-a".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::ToolResult {
|
||||
tool_use_id: "tool_bash_1".to_string(),
|
||||
content: "[Tool 'bash' interrupted by server reload after 0.2s]".to_string(),
|
||||
is_error: Some(true),
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
let subscribe_events = collect_until_done_unix(&mut client, subscribe_id).await?;
|
||||
assert!(
|
||||
subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Done { id } if *id == subscribe_id)),
|
||||
"expected subscribe to finish, got: {subscribe_events:?}"
|
||||
);
|
||||
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
let events = collect_until_done_unix(&mut client, resume_id).await?;
|
||||
|
||||
let history_event = events
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
was_interrupted,
|
||||
..
|
||||
} if *id == resume_id => Some((session_id.clone(), *was_interrupted)),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Did not receive resume history event: {events:?}"))?;
|
||||
|
||||
assert_eq!(history_event.0, session.id);
|
||||
assert_eq!(
|
||||
history_event.1,
|
||||
Some(true),
|
||||
"reload-interrupted peer sessions should be marked interrupted so clients auto-continue"
|
||||
);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that subscribe selfdev hint marks the session as canary
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_selfdev_hint_marks_canary() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(None, Some(true), None, false, false)
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == subscribe_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let history_event = client.get_history_event().await?;
|
||||
match history_event {
|
||||
ServerEvent::History { is_canary, .. } => {
|
||||
assert_eq!(is_canary, Some(true));
|
||||
}
|
||||
_ => anyhow::bail!("Expected history event after subscribe"),
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that working_dir alone no longer upgrades a session to self-dev.
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_working_dir_without_selfdev_hint_stays_normal() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let fake_repo = tempfile::tempdir()?;
|
||||
std::fs::create_dir_all(fake_repo.path().join(".git"))?;
|
||||
std::fs::write(
|
||||
fake_repo.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"jcode\"\nversion = \"0.0.0\"\n",
|
||||
)?;
|
||||
let nested_dir = fake_repo.path().join("nested").join("worktree");
|
||||
std::fs::create_dir_all(&nested_dir)?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(
|
||||
Some(nested_dir.display().to_string()),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == subscribe_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let history_event = client.get_history_event().await?;
|
||||
match history_event {
|
||||
ServerEvent::History { is_canary, .. } => {
|
||||
assert_eq!(is_canary, Some(false));
|
||||
}
|
||||
_ => anyhow::bail!("Expected history event after subscribe"),
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that switching models resets the provider resume session
|
||||
#[tokio::test]
|
||||
async fn test_model_switch_resets_provider_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a", "model-b"]));
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("hello".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-1".to_string()),
|
||||
]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("again".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
|
||||
let msg_id = client.send_message("hello").await?;
|
||||
let mut saw_done1 = false;
|
||||
// First-turn Done includes full agent-loop startup; slow Windows CI
|
||||
// runners intermittently exceeded the old 5s budget (CI flake). Keep the
|
||||
// loop tolerant of individual read timeouts and bound only the total wait.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let Ok(event) = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(event?, ServerEvent::Done { id } if id == msg_id) {
|
||||
saw_done1 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_done1, "Did not receive Done for first message");
|
||||
|
||||
let model_id = client.cycle_model(1).await?;
|
||||
let mut saw_model = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let Ok(event) = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(event?, ServerEvent::ModelChanged { id, error: None, .. } if id == model_id) {
|
||||
saw_model = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_model, "Did not receive ModelChanged after cycle");
|
||||
|
||||
let msg2_id = client.send_message("second").await?;
|
||||
let mut saw_done2 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let Ok(event) = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(event?, ServerEvent::Done { id } if id == msg2_id) {
|
||||
saw_done2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_done2, "Did not receive Done for second message");
|
||||
|
||||
let resume_ids = provider.captured_resume_session_ids.lock().unwrap().clone();
|
||||
assert_eq!(resume_ids.len(), 2);
|
||||
assert_eq!(resume_ids[0], None);
|
||||
assert_eq!(resume_ids[1], None);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that switching models only affects the active session
|
||||
#[tokio::test]
|
||||
async fn test_model_switch_is_per_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a", "model-b"]));
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("one".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-1".to_string()),
|
||||
]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("two".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-2".to_string()),
|
||||
]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("three".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client1 = wait_for_server_client(&socket_path).await?;
|
||||
let mut client2 = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
|
||||
// Give server time to set up both client sessions
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let msg1 = client1.send_message("hello").await?;
|
||||
let mut done1 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client1.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg1) {
|
||||
done1 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(done1, "Did not receive Done for client1 message");
|
||||
|
||||
let msg2 = client2.send_message("hello").await?;
|
||||
let mut done2 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client2.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg2) {
|
||||
done2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(done2, "Did not receive Done for client2 message");
|
||||
|
||||
let model_id = client1.cycle_model(1).await?;
|
||||
let mut saw_model = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client1.read_event()).await??;
|
||||
if matches!(event, ServerEvent::ModelChanged { id, error: None, .. } if id == model_id) {
|
||||
saw_model = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_model, "Did not receive ModelChanged after cycle");
|
||||
|
||||
let msg3 = client2.send_message("after").await?;
|
||||
let mut done3 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client2.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg3) {
|
||||
done3 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(done3, "Did not receive Done for client2 after switch");
|
||||
|
||||
let models = provider.captured_models.lock().unwrap().clone();
|
||||
assert!(models.len() >= 3, "Expected at least 3 model captures");
|
||||
assert_eq!(models[2], "model-a");
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that the system prompt does NOT identify the agent as "Claude Code"
|
||||
/// The agent should identify as "jcode" or just a generic "coding assistant powered by Claude"
|
||||
#[tokio::test]
|
||||
async fn test_system_prompt_no_claude_code_identity() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = Arc::new(MockProvider::new());
|
||||
|
||||
// Queue a simple response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("I'm a coding assistant.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("test-identity-123".to_string()),
|
||||
]);
|
||||
|
||||
// Keep a clone of Arc<MockProvider> before converting to Arc<dyn Provider>
|
||||
let provider_for_check = provider.clone();
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let registry = Registry::new(provider_dyn.clone()).await;
|
||||
let mut agent = Agent::new(provider_dyn, registry);
|
||||
|
||||
// Run a simple query - we just need to trigger a complete() call
|
||||
let _ = agent.run_once_capture("Who are you?").await?;
|
||||
|
||||
// Get the captured system prompt from our Arc<MockProvider>
|
||||
let captured_prompts = provider_for_check.captured_system_prompts.lock().unwrap();
|
||||
|
||||
assert!(
|
||||
!captured_prompts.is_empty(),
|
||||
"No system prompts were captured"
|
||||
);
|
||||
|
||||
let system_prompt = &captured_prompts[0];
|
||||
|
||||
// Check only the identity portion at the start of the system prompt.
|
||||
// User-provided instruction files may legitimately mention Claude Code CLI.
|
||||
// The first ~500 chars contain the identity statement
|
||||
let identity_portion = if system_prompt.len() > 500 {
|
||||
&system_prompt[..500]
|
||||
} else {
|
||||
system_prompt
|
||||
};
|
||||
let lower_identity = identity_portion.to_lowercase();
|
||||
|
||||
// The identity portion should NOT say "you are claude code" or similar
|
||||
assert!(
|
||||
!lower_identity.contains("you are claude code"),
|
||||
"System prompt should NOT identify as 'You are Claude Code'. Found: {}",
|
||||
identity_portion
|
||||
);
|
||||
|
||||
// Should identify as jcode
|
||||
assert!(
|
||||
lower_identity.contains("jcode"),
|
||||
"System prompt should identify as jcode. Found: {}",
|
||||
identity_portion
|
||||
);
|
||||
|
||||
// It's OK if it says "powered by Claude" or just "Claude" (the model name)
|
||||
// It's OK if project context references "Claude Code CLI" as a tool
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! In-process multi-client reload tests.
|
||||
//!
|
||||
//! These exercise the real server reload fan-out and streaming-interrupt paths
|
||||
//! against a `MockProvider` with multiple clients attached, without execing a
|
||||
//! new binary (the server runs with `JCODE_TEST_SESSION=1`, so
|
||||
//! `await_reload_signal` performs all the in-process bookkeeping -- recovery
|
||||
//! intents, graceful shutdown, marker writes, and client fan-out -- but skips
|
||||
//! the final `exec`). This is the cheapest way to cover the multi-client +
|
||||
//! streaming behavior that the binary-level reload tests cannot reach without
|
||||
//! real credentials and a release build.
|
||||
|
||||
use crate::test_support::*;
|
||||
|
||||
/// Spin up an in-process server backed by a `MockProvider`.
|
||||
async fn start_inprocess_server(
|
||||
label: &str,
|
||||
provider: Arc<dyn jcode::provider::Provider>,
|
||||
) -> Result<(
|
||||
std::path::PathBuf,
|
||||
std::path::PathBuf,
|
||||
tokio::task::JoinHandle<Result<()>>,
|
||||
)> {
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-reload-mc-{label}-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
wait_for_socket(&socket_path).await?;
|
||||
Ok((socket_path, debug_socket_path, server_handle))
|
||||
}
|
||||
|
||||
/// Read events from `client` until a `Reloading` event is seen or the deadline
|
||||
/// elapses. Returns whether a `Reloading` event was observed plus the events
|
||||
/// seen. A clean disconnect terminates the scan (returning `false`).
|
||||
async fn read_until_reloading(
|
||||
client: &mut server::Client,
|
||||
deadline: Instant,
|
||||
) -> Result<(bool, Vec<ServerEvent>)> {
|
||||
let mut seen = Vec::new();
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client.read_event()).await {
|
||||
Ok(Ok(event)) => {
|
||||
let is_reloading = matches!(event, ServerEvent::Reloading { .. });
|
||||
seen.push(event);
|
||||
if is_reloading {
|
||||
return Ok((true, seen));
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => return Ok((false, seen)),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok((false, seen))
|
||||
}
|
||||
|
||||
async fn subscribe_new_session(socket_path: &std::path::Path) -> Result<(server::Client, String)> {
|
||||
let mut client = server::Client::connect_with_path(socket_path.to_path_buf()).await?;
|
||||
let sub = client.subscribe().await?;
|
||||
let _ = collect_until_done_unix(&mut client, sub).await?;
|
||||
let history = client.get_history_event().await?;
|
||||
let session_id = match &history {
|
||||
ServerEvent::History { session_id, .. } => session_id.clone(),
|
||||
other => anyhow::bail!("expected history, got {other:?}"),
|
||||
};
|
||||
Ok((client, session_id))
|
||||
}
|
||||
|
||||
/// Clients attached to MULTIPLE independent sessions must each be notified when
|
||||
/// any client triggers a reload, because a reload restarts the whole server.
|
||||
/// This exercises `handle_reload`'s fan-out across every live session's
|
||||
/// `event_txs`, not just the triggering session.
|
||||
#[tokio::test]
|
||||
async fn reload_notifies_clients_across_independent_sessions() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("multisession", provider).await?;
|
||||
|
||||
let result = async {
|
||||
let (mut client_a, _sid_a) = subscribe_new_session(&socket_path).await?;
|
||||
let (mut client_b, _sid_b) = subscribe_new_session(&socket_path).await?;
|
||||
let (mut client_c, _sid_c) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
// Let all three senders register.
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// Only client A asks for the reload.
|
||||
client_a.reload().await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let (a_saw, a_events) = read_until_reloading(&mut client_a, deadline).await?;
|
||||
let (b_saw, b_events) = read_until_reloading(&mut client_b, deadline).await?;
|
||||
let (c_saw, c_events) = read_until_reloading(&mut client_c, deadline).await?;
|
||||
|
||||
let fmt = |events: &[ServerEvent]| {
|
||||
events.iter().map(|e| format!("{e:?}")).collect::<Vec<_>>()
|
||||
};
|
||||
assert!(a_saw, "initiator session must be notified; saw: {:?}", fmt(&a_events));
|
||||
assert!(
|
||||
b_saw,
|
||||
"a client on a SECOND independent session must be notified the server is reloading; saw: {:?}",
|
||||
fmt(&b_events)
|
||||
);
|
||||
assert!(
|
||||
c_saw,
|
||||
"a client on a THIRD independent session must be notified the server is reloading; saw: {:?}",
|
||||
fmt(&c_events)
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// When a client takes over a live session (the multi-client reconnect path),
|
||||
/// the SUCCESSOR connection must still receive the reload notification and the
|
||||
/// superseded connection must be cleanly disconnected -- not left hanging.
|
||||
#[tokio::test]
|
||||
async fn reload_notifies_successor_after_session_takeover() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("takeover", provider).await?;
|
||||
|
||||
let result = async {
|
||||
// Original owner.
|
||||
let (mut client_a, session_id) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
// Successor reconnects to the same session with takeover semantics.
|
||||
let mut client_b = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
let resume_b = client_b
|
||||
.resume_session_with_options(&session_id, true, true)
|
||||
.await?;
|
||||
let _ = collect_until_history_unix(&mut client_b, resume_b).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// The successor triggers the reload.
|
||||
client_b.reload().await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let (b_saw, b_events) = read_until_reloading(&mut client_b, deadline).await?;
|
||||
assert!(
|
||||
b_saw,
|
||||
"the live successor connection must be told the server is reloading; saw: {:?}",
|
||||
b_events
|
||||
.iter()
|
||||
.map(|e| format!("{e:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// The superseded original connection must end (disconnect) rather than
|
||||
// hang indefinitely. It may have received an Error/Reloading first.
|
||||
let a_deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut a_terminated = false;
|
||||
while Instant::now() < a_deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client_a.read_event()).await {
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => {
|
||||
a_terminated = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
a_terminated,
|
||||
"the superseded original connection must be cleanly disconnected, not left hanging"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// A client with an in-flight STREAMING turn during a reload must reach a
|
||||
/// terminal state (Reloading and/or Done) without hanging. This drives the
|
||||
/// graceful-shutdown interrupt path inside `run_turn_streaming_mpsc` where the
|
||||
/// server fires each session's shutdown signal mid-stream.
|
||||
#[tokio::test]
|
||||
async fn reload_interrupts_in_flight_streaming_turn_without_hanging() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
// A long stream of text deltas with NO MessageEnd: the turn stays "running"
|
||||
// so the reload graceful-shutdown path must interrupt it. The mock yields
|
||||
// all events eagerly, but the server still processes the stream while the
|
||||
// reload signal races in; the key assertion is that the streaming client
|
||||
// reaches a terminal event quickly rather than hanging.
|
||||
let mut long_stream = Vec::new();
|
||||
for i in 0..2000 {
|
||||
long_stream.push(StreamEvent::TextDelta(format!("chunk-{i} ")));
|
||||
}
|
||||
long_stream.push(StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
});
|
||||
provider.queue_response(long_stream);
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("streaming", provider).await?;
|
||||
|
||||
let result = async {
|
||||
let (mut client, _session_id) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
// Kick off the streaming turn.
|
||||
let msg_id = client.send_message("please stream a lot").await?;
|
||||
|
||||
// Wait until the stream is actually flowing before reloading.
|
||||
let mut saw_text = false;
|
||||
let stream_deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < stream_deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client.read_event()).await {
|
||||
Ok(Ok(ServerEvent::TextDelta { .. })) => {
|
||||
saw_text = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(saw_text, "streaming turn should emit text before reload");
|
||||
|
||||
// Reload while the turn is in flight.
|
||||
client.reload().await?;
|
||||
|
||||
// The client must reach a terminal state quickly: either Reloading,
|
||||
// a Done for the message, or a disconnect. It must NOT hang.
|
||||
let mut terminal = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client.read_event()).await {
|
||||
Ok(Ok(ServerEvent::Reloading { .. })) => {
|
||||
terminal = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(ServerEvent::Done { id })) if id == msg_id => {
|
||||
terminal = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => {
|
||||
terminal = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
terminal,
|
||||
"a streaming turn interrupted by reload must reach a terminal state (Reloading/Done/disconnect) without hanging"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// The reload marker must be active around the time clients are told to
|
||||
/// reconnect, so that a client which disconnects mid-reload is classified as
|
||||
/// `Reloading` rather than `Crashed` by `disconnect_disposition`.
|
||||
#[tokio::test]
|
||||
async fn reload_marker_is_active_when_clients_are_notified() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("marker", provider).await?;
|
||||
|
||||
let result = async {
|
||||
let (mut client, _session_id) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
client.reload().await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let (saw_reloading, events) = read_until_reloading(&mut client, deadline).await?;
|
||||
assert!(
|
||||
saw_reloading,
|
||||
"client should observe Reloading; saw: {:?}",
|
||||
events.iter().map(|e| format!("{e:?}")).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let mut marker_active = false;
|
||||
let marker_deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < marker_deadline {
|
||||
if server::reload_marker_active(Duration::from_secs(30)) {
|
||||
marker_active = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
assert!(
|
||||
marker_active,
|
||||
"the reload marker must be active around the time clients are told to reconnect"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// =============================================================================
|
||||
// Ambient Mode Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Test safety system: action classification
|
||||
#[test]
|
||||
fn test_safety_classification() {
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
// Tier 1: auto-allowed
|
||||
assert!(safety.classify("read") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("glob") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("grep") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("memory") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("todoread") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("todowrite") == jcode::safety::ActionTier::AutoAllowed);
|
||||
|
||||
// Tier 2: requires permission
|
||||
assert!(safety.classify("bash") == jcode::safety::ActionTier::RequiresPermission);
|
||||
assert!(safety.classify("edit") == jcode::safety::ActionTier::RequiresPermission);
|
||||
assert!(safety.classify("write") == jcode::safety::ActionTier::RequiresPermission);
|
||||
assert!(
|
||||
safety.classify("create_pull_request") == jcode::safety::ActionTier::RequiresPermission
|
||||
);
|
||||
assert!(safety.classify("send_email") == jcode::safety::ActionTier::RequiresPermission);
|
||||
|
||||
// Case insensitive
|
||||
assert!(safety.classify("READ") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("Bash") == jcode::safety::ActionTier::RequiresPermission);
|
||||
}
|
||||
|
||||
/// Test safety system: permission request queue + decision flow
|
||||
#[test]
|
||||
fn test_safety_permission_flow() {
|
||||
use jcode::safety::{PermissionRequest, PermissionResult, SafetySystem, Urgency};
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
// Count existing pending requests (may have leftover state from other tests)
|
||||
let baseline = safety.pending_requests().len();
|
||||
|
||||
// Queue a permission request
|
||||
let req = PermissionRequest {
|
||||
id: "test_perm_flow_001".to_string(),
|
||||
action: "create_pull_request".to_string(),
|
||||
description: "Create PR for auth fixes".to_string(),
|
||||
rationale: "Found 3 failing auth tests".to_string(),
|
||||
urgency: Urgency::High,
|
||||
wait: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
context: None,
|
||||
};
|
||||
|
||||
let result = safety.request_permission(req);
|
||||
assert!(matches!(result, PermissionResult::Queued { .. }));
|
||||
|
||||
// Verify our request was added
|
||||
let pending = safety.pending_requests();
|
||||
assert_eq!(pending.len(), baseline + 1);
|
||||
assert!(
|
||||
pending
|
||||
.iter()
|
||||
.any(|p| p.action == "create_pull_request" && p.id == "test_perm_flow_001")
|
||||
);
|
||||
|
||||
// Record an approval decision
|
||||
let _ = safety.record_decision(
|
||||
"test_perm_flow_001",
|
||||
true,
|
||||
"test",
|
||||
Some("looks good".to_string()),
|
||||
);
|
||||
|
||||
// Verify our request was removed
|
||||
assert_eq!(safety.pending_requests().len(), baseline);
|
||||
}
|
||||
|
||||
/// Test safety system: transcript saving
|
||||
#[test]
|
||||
fn test_safety_transcript() {
|
||||
use jcode::safety::{AmbientTranscript, SafetySystem, TranscriptStatus};
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
let transcript = AmbientTranscript {
|
||||
session_id: "test_ambient_001".to_string(),
|
||||
started_at: chrono::Utc::now(),
|
||||
ended_at: Some(chrono::Utc::now()),
|
||||
status: TranscriptStatus::Complete,
|
||||
provider: "mock".to_string(),
|
||||
model: "mock-model".to_string(),
|
||||
actions: vec![],
|
||||
pending_permissions: 0,
|
||||
summary: Some("Test cycle completed".to_string()),
|
||||
compactions: 0,
|
||||
memories_modified: 3,
|
||||
conversation: None,
|
||||
};
|
||||
|
||||
// Should not panic
|
||||
let result = safety.save_transcript(&transcript);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// Test safety system: summary generation
|
||||
#[test]
|
||||
fn test_safety_summary_generation() {
|
||||
use jcode::safety::{ActionLog, ActionTier, SafetySystem};
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
// Log some actions
|
||||
safety.log_action(ActionLog {
|
||||
action_type: "memory_consolidation".to_string(),
|
||||
description: "Merged 2 duplicate memories".to_string(),
|
||||
tier: ActionTier::AutoAllowed,
|
||||
details: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
safety.log_action(ActionLog {
|
||||
action_type: "memory_prune".to_string(),
|
||||
description: "Pruned 1 stale memory".to_string(),
|
||||
tier: ActionTier::AutoAllowed,
|
||||
details: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
let summary = safety.generate_summary();
|
||||
assert!(summary.contains("Merged 2 duplicate memories"));
|
||||
assert!(summary.contains("Pruned 1 stale memory"));
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_session_restores_persisted_compaction_for_provider_context() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-compaction-resume-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = CapturingCompactionProvider::new();
|
||||
let captured_messages = provider.captured_messages();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let result = async {
|
||||
let mut session = Session::create_with_id(
|
||||
"session_resume_compaction_restore_test".to_string(),
|
||||
None,
|
||||
Some("resume compaction restore test".to_string()),
|
||||
);
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "older user turn".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: "older assistant turn".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "recent preserved turn".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.compaction = Some(StoredCompactionState {
|
||||
summary_text: "Worked on Gemini OAuth reload fixes.".to_string(),
|
||||
openai_encrypted_content: None,
|
||||
covers_up_to_turn: 2,
|
||||
original_turn_count: 2,
|
||||
compacted_count: 2,
|
||||
});
|
||||
session.save()?;
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let mut client = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
let _ = collect_until_done_unix(&mut client, subscribe_id).await?;
|
||||
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
let _ = collect_until_history_unix(&mut client, resume_id).await?;
|
||||
|
||||
let message_id = client
|
||||
.send_message("continue from the restored session")
|
||||
.await?;
|
||||
let mut seen_events = Vec::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let event = timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
let is_done = matches!(event, ServerEvent::Done { id } if id == message_id);
|
||||
let is_error = matches!(event, ServerEvent::Error { id, .. } if id == message_id);
|
||||
seen_events.push(format!("{event:?}"));
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
if is_error {
|
||||
anyhow::bail!(
|
||||
"message request failed while validating compaction restore: {}",
|
||||
seen_events.join(" | ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let captured = captured_messages.lock().unwrap();
|
||||
assert_eq!(
|
||||
captured.len(),
|
||||
1,
|
||||
"expected exactly one provider completion call"
|
||||
);
|
||||
let provider_messages = &captured[0];
|
||||
assert!(
|
||||
provider_messages.len() >= 3,
|
||||
"expected summary + preserved tail + new user message"
|
||||
);
|
||||
|
||||
let summary_text = flatten_text_blocks(&provider_messages[0]);
|
||||
assert!(summary_text.contains("Previous Conversation Summary"));
|
||||
assert!(summary_text.contains("Gemini OAuth reload fixes"));
|
||||
|
||||
let joined = provider_messages
|
||||
.iter()
|
||||
.map(flatten_text_blocks)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n---\n");
|
||||
assert!(joined.contains("recent preserved turn"));
|
||||
assert!(joined.contains("continue from the restored session"));
|
||||
assert!(!joined.contains("older user turn"));
|
||||
assert!(!joined.contains("older assistant turn"));
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// Test that a simple text response works
|
||||
#[tokio::test]
|
||||
async fn test_simple_response() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// Queue a simple response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Hello! ".to_string()),
|
||||
StreamEvent::TextDelta("How can I help?".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("test-session-123".to_string()),
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Say hello").await?;
|
||||
let saved = Session::load(agent.session_id())?;
|
||||
|
||||
assert_eq!(response, "Hello! How can I help?");
|
||||
assert!(saved.is_debug, "test sessions should be marked debug");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_clear_preserves_debug_flag() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
agent.set_debug(true);
|
||||
let old_session_id = agent.session_id().to_string();
|
||||
|
||||
agent.clear();
|
||||
|
||||
assert_ne!(agent.session_id(), old_session_id);
|
||||
assert!(agent.is_debug());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_debug_create_session_marks_debug() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-debug-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = debug_create_headless_session(debug_socket_path.clone()).await?;
|
||||
let session = Session::load(&session_id)?;
|
||||
assert!(session.is_debug);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_debug_create_selfdev_session_marks_canary() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-debug-selfdev-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = debug_create_headless_session_with_command(
|
||||
debug_socket_path.clone(),
|
||||
"create_session:selfdev:/tmp",
|
||||
)
|
||||
.await?;
|
||||
let session = Session::load(&session_id)?;
|
||||
assert!(session.is_debug);
|
||||
assert!(session.is_canary);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_preserves_debug_for_resumed_debug_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-clear-debug-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let debug_session_id = debug_create_headless_session(debug_socket_path.clone()).await?;
|
||||
let mut client = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
let resume_id = client.resume_session(&debug_session_id).await?;
|
||||
|
||||
// Drain resume completion so clear() events are unambiguous.
|
||||
let mut saw_resume_history = false;
|
||||
let resume_deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < resume_deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::History { id, .. } if id == resume_id => {
|
||||
saw_resume_history = true;
|
||||
break;
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == resume_id => {
|
||||
anyhow::bail!("resume_session failed: {}", message);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !saw_resume_history {
|
||||
anyhow::bail!("Timed out waiting for resume history event");
|
||||
}
|
||||
|
||||
client.clear().await?;
|
||||
|
||||
let mut new_session_id = None;
|
||||
let clear_deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < clear_deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::SessionId { session_id } => {
|
||||
new_session_id = Some(session_id);
|
||||
}
|
||||
ServerEvent::Done { .. } if new_session_id.is_some() => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let new_session_id = new_session_id
|
||||
.ok_or_else(|| anyhow::anyhow!("Did not receive new session id after clear"))?;
|
||||
assert_ne!(new_session_id, debug_session_id);
|
||||
let session = Session::load(&new_session_id)?;
|
||||
assert!(session.is_debug);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_websocket_transport_matches_unix_socket_for_subscribe_history_message_and_resume()
|
||||
-> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let unix = run_unix_transport_scenario().await?;
|
||||
let websocket = run_websocket_transport_scenario().await?;
|
||||
|
||||
assert!(
|
||||
unix.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Ack { id } if *id == 1))
|
||||
);
|
||||
assert!(
|
||||
unix.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 1))
|
||||
);
|
||||
assert!(
|
||||
websocket
|
||||
.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Ack { id } if *id == 1))
|
||||
);
|
||||
assert!(
|
||||
websocket
|
||||
.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 1))
|
||||
);
|
||||
|
||||
let unix_history = unix
|
||||
.history_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing unix history event"))?;
|
||||
let websocket_history = websocket
|
||||
.history_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing websocket history event"))?;
|
||||
assert_eq!(
|
||||
unix_history, websocket_history,
|
||||
"history payload should match across transports"
|
||||
);
|
||||
|
||||
let unix_resume = unix
|
||||
.resume_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing unix resume history event"))?;
|
||||
let websocket_resume = websocket
|
||||
.resume_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing websocket resume history event"))?;
|
||||
assert_eq!(
|
||||
unix_resume, websocket_resume,
|
||||
"resume history payload should match across transports"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
struct SpawnedWindowsServer {
|
||||
_temp_root: tempfile::TempDir,
|
||||
home_dir: std::path::PathBuf,
|
||||
runtime_dir: std::path::PathBuf,
|
||||
install_dir: std::path::PathBuf,
|
||||
socket_path: std::path::PathBuf,
|
||||
debug_socket_path: std::path::PathBuf,
|
||||
stdout_path: std::path::PathBuf,
|
||||
stderr_path: std::path::PathBuf,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl SpawnedWindowsServer {
|
||||
fn jcode_binary() -> std::path::PathBuf {
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let release_binary = manifest_dir
|
||||
.join("target")
|
||||
.join("x86_64-pc-windows-msvc")
|
||||
.join("release")
|
||||
.join("jcode.exe");
|
||||
if release_binary.exists() {
|
||||
return release_binary;
|
||||
}
|
||||
std::path::PathBuf::from(env!("CARGO_BIN_EXE_jcode"))
|
||||
}
|
||||
|
||||
fn spawn(prefix: &str) -> Result<Self> {
|
||||
let temp_root = tempfile::Builder::new().prefix(prefix).tempdir()?;
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
let stdout_path = temp_root.path().join("server-stdout.log");
|
||||
let stderr_path = temp_root.path().join("server-stderr.log");
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode-windows-lifecycle.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-windows-lifecycle-debug.sock");
|
||||
|
||||
let stdout_file = std::fs::File::create(&stdout_path)?;
|
||||
let stderr_file = std::fs::File::create(&stderr_path)?;
|
||||
let mut command = Command::new(Self::jcode_binary());
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&socket_path)
|
||||
.arg("--provider")
|
||||
.arg("openai-compatible")
|
||||
.arg("--model")
|
||||
.arg("windows-e2e-model")
|
||||
.arg("serve")
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir)
|
||||
.env("JCODE_NO_TELEMETRY", "1")
|
||||
.env("JCODE_OPENAI_COMPAT_API_BASE", "http://127.0.0.1:9/v1")
|
||||
.env("JCODE_OPENAI_COMPAT_DEFAULT_MODEL", "windows-e2e-model")
|
||||
.env("JCODE_OPENAI_COMPAT_LOCAL_ENABLED", "1")
|
||||
.env("JCODE_DEBUG_CONTROL", "1")
|
||||
.env("JCODE_TEMP_SERVER", "1")
|
||||
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
|
||||
.env("RUST_BACKTRACE", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
let child = command.spawn()?;
|
||||
|
||||
Ok(Self {
|
||||
_temp_root: temp_root,
|
||||
home_dir,
|
||||
runtime_dir,
|
||||
install_dir,
|
||||
socket_path,
|
||||
debug_socket_path,
|
||||
stdout_path,
|
||||
stderr_path,
|
||||
child,
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_ready(&self) -> Result<()> {
|
||||
wait_for_server_ready(&self.socket_path, &self.debug_socket_path).await
|
||||
}
|
||||
|
||||
fn apply_env<'a>(&self, command: &'a mut Command) -> &'a mut Command {
|
||||
command
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &self.home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &self.runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &self.install_dir)
|
||||
.env("JCODE_NO_TELEMETRY", "1")
|
||||
.env("JCODE_OPENAI_COMPAT_API_BASE", "http://127.0.0.1:9/v1")
|
||||
.env("JCODE_OPENAI_COMPAT_DEFAULT_MODEL", "windows-e2e-model")
|
||||
.env("JCODE_OPENAI_COMPAT_LOCAL_ENABLED", "1")
|
||||
.env("JCODE_DEBUG_CONTROL", "1")
|
||||
.env("JCODE_TEMP_SERVER", "1")
|
||||
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
|
||||
.env("RUST_BACKTRACE", "1")
|
||||
}
|
||||
|
||||
fn jcode_command(&self) -> Command {
|
||||
let mut command = Command::new(Self::jcode_binary());
|
||||
self.apply_env(&mut command);
|
||||
command
|
||||
}
|
||||
|
||||
fn spawn_same_socket_child(
|
||||
&self,
|
||||
label: &str,
|
||||
) -> Result<(Child, std::path::PathBuf, std::path::PathBuf)> {
|
||||
let stdout_path = self._temp_root.path().join(format!("{label}-stdout.log"));
|
||||
let stderr_path = self._temp_root.path().join(format!("{label}-stderr.log"));
|
||||
let stdout_file = std::fs::File::create(&stdout_path)?;
|
||||
let stderr_file = std::fs::File::create(&stderr_path)?;
|
||||
let mut command = Command::new(Self::jcode_binary());
|
||||
self.apply_env(&mut command)
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&self.socket_path)
|
||||
.arg("--provider")
|
||||
.arg("openai-compatible")
|
||||
.arg("--model")
|
||||
.arg("windows-e2e-model")
|
||||
.arg("serve")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
let child = command.spawn()?;
|
||||
Ok((child, stdout_path, stderr_path))
|
||||
}
|
||||
|
||||
fn dump_extra_logs(
|
||||
&self,
|
||||
label: &str,
|
||||
stdout_path: &std::path::Path,
|
||||
stderr_path: &std::path::Path,
|
||||
) {
|
||||
eprintln!("=== {label}: extra process diagnostics ===");
|
||||
for (name, path) in [
|
||||
("server stdout", stdout_path),
|
||||
("server stderr", stderr_path),
|
||||
] {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) if !contents.trim().is_empty() => {
|
||||
eprintln!("--- {name} ({}) ---\n{contents}", path.display());
|
||||
}
|
||||
Ok(_) => eprintln!("--- {name} ({}) was empty ---", path.display()),
|
||||
Err(err) => eprintln!("--- could not read {name} at {}: {err} ---", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_logs(&self, label: &str) {
|
||||
eprintln!("=== {label}: windows lifecycle server diagnostics ===");
|
||||
for (name, path) in [
|
||||
("server stdout", &self.stdout_path),
|
||||
("server stderr", &self.stderr_path),
|
||||
] {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) if !contents.trim().is_empty() => {
|
||||
eprintln!("--- {name} ({}) ---\n{contents}", path.display());
|
||||
}
|
||||
Ok(_) => eprintln!("--- {name} ({}) was empty ---", path.display()),
|
||||
Err(err) => eprintln!("--- could not read {name} at {}: {err} ---", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(artifact_root) = std::env::var("JCODE_E2E_ARTIFACT_DIR") {
|
||||
let safe_label: String = label
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
|
||||
.collect();
|
||||
let artifact_dir = std::path::PathBuf::from(artifact_root).join(safe_label);
|
||||
let _ = std::fs::create_dir_all(&artifact_dir);
|
||||
let _ = std::fs::copy(&self.stdout_path, artifact_dir.join("server-stdout.log"));
|
||||
let _ = std::fs::copy(&self.stderr_path, artifact_dir.join("server-stderr.log"));
|
||||
let logs_dir = self.home_dir.join("logs");
|
||||
if let Ok(entries) = std::fs::read_dir(logs_dir) {
|
||||
let copied_logs_dir = artifact_dir.join("jcode-logs");
|
||||
let _ = std::fs::create_dir_all(&copied_logs_dir);
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
let _ = std::fs::copy(path, copied_logs_dir.join(entry.file_name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SpawnedWindowsServer {
|
||||
fn drop(&mut self) {
|
||||
kill_child(&mut self.child);
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_server_unreachable(socket_path: &std::path::Path) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let connect_result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
server::Client::connect_with_path(socket_path.to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
match connect_result {
|
||||
Err(_) if Instant::now() >= deadline => {
|
||||
anyhow::bail!(
|
||||
"timed out waiting for server at {} to become unreachable after process exit",
|
||||
socket_path.display()
|
||||
);
|
||||
}
|
||||
Err(_) => {}
|
||||
Ok(Err(_)) => return Ok(()),
|
||||
Ok(Ok(mut client)) => {
|
||||
if client.ping().await.unwrap_or(false) {
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"server at {} remained reachable after process exit",
|
||||
socket_path.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_binary_server_accepts_clients_and_debug_cli() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let server = SpawnedWindowsServer::spawn("jcode-windows-lifecycle-")?;
|
||||
|
||||
let result = async {
|
||||
server.wait_ready().await?;
|
||||
|
||||
let mut client_a = wait_for_server_client(&server.socket_path).await?;
|
||||
anyhow::ensure!(client_a.ping().await?, "first protocol client should ping");
|
||||
|
||||
let mut client_b = wait_for_server_client(&server.socket_path).await?;
|
||||
anyhow::ensure!(client_b.ping().await?, "second protocol client should ping");
|
||||
|
||||
let info = debug_run_command_json(server.debug_socket_path.clone(), "server:info", None).await?;
|
||||
anyhow::ensure!(
|
||||
info.get("id").and_then(|value| value.as_str()).is_some(),
|
||||
"server:info should include an id: {info}"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
info.get("debug_control_enabled")
|
||||
.and_then(|value| value.as_bool())
|
||||
== Some(true),
|
||||
"server should honor JCODE_DEBUG_CONTROL in Windows e2e"
|
||||
);
|
||||
|
||||
let output = server
|
||||
.jcode_command()
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&server.socket_path)
|
||||
.arg("debug")
|
||||
.arg("server:info")
|
||||
.output()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"debug CLI should round-trip to spawned Windows server. stdout: {stdout}; stderr: {stderr}"
|
||||
);
|
||||
let cli_info: serde_json::Value = serde_json::from_str(stdout.trim())?;
|
||||
anyhow::ensure!(
|
||||
cli_info.get("id") == info.get("id"),
|
||||
"debug CLI should target the same server as the protocol client"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
server.dump_logs("binary-server-accepts-clients-and-debug-cli");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_binary_server_rebinds_named_pipe_after_exit() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let mut first = SpawnedWindowsServer::spawn("jcode-windows-rebind-")?;
|
||||
|
||||
let result = async {
|
||||
first.wait_ready().await?;
|
||||
let socket_path = first.socket_path.clone();
|
||||
let debug_socket_path = first.debug_socket_path.clone();
|
||||
kill_child(&mut first.child);
|
||||
wait_for_server_unreachable(&socket_path).await?;
|
||||
|
||||
let (mut second_child, second_stdout, second_stderr) =
|
||||
first.spawn_same_socket_child("second-server")?;
|
||||
let second_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
anyhow::ensure!(client.ping().await?, "rebound server should answer ping");
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
kill_child(&mut second_child);
|
||||
if second_result.is_err() {
|
||||
first.dump_extra_logs(
|
||||
"binary-server-rebind-second-server",
|
||||
&second_stdout,
|
||||
&second_stderr,
|
||||
);
|
||||
}
|
||||
second_result
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
first.dump_logs("binary-server-rebind-first-server");
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Status: I detected pre-existing local edits in unrelated files; I will leave those untouched and only change targeted files for this perf work.
|
||||
|
||||
Next I am inspecting session-lookup/resume internals (session.rs, client connect path, self-dev path) to pinpoint the slow path before running profiles.
|
||||
to=functions.batch commentary {}json
|
||||
{"tool_calls":[{"tool":"read","file_path":"src/session.rs","offset":0,"limit":420},{"tool":"read","file_path":"src/main.rs","offset":2320,"limit":220},{"tool":"read","file_path":"src/main.rs","offset":2860,"limit":360}]}
|
||||
@@ -0,0 +1,924 @@
|
||||
use anyhow::Result;
|
||||
use jcode::auth::{AuthState, AuthStatus};
|
||||
use jcode::cli::provider_init::{
|
||||
ProviderChoice, apply_login_provider_profile_env, choice_for_login_provider,
|
||||
init_provider_for_validation,
|
||||
};
|
||||
use jcode::provider::Provider;
|
||||
use jcode::provider_catalog::{
|
||||
LoginProviderDescriptor, LoginProviderTarget, OPENAI_COMPAT_PROFILE, OpenAiCompatibleProfile,
|
||||
apply_openai_compatible_profile_env, load_api_key_from_env_or_config, login_providers,
|
||||
openai_compatible_profile_is_configured, openai_compatible_profiles,
|
||||
resolve_openai_compatible_profile, save_env_value_to_env_file,
|
||||
server_bootstrap_login_providers,
|
||||
};
|
||||
use jcode_provider_openrouter_runtime::OpenRouterProvider;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
|
||||
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
|
||||
fn lock_env() -> MutexGuard<'static, ()> {
|
||||
let mutex = ENV_LOCK.get_or_init(|| Mutex::new(()));
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn tracked_env_vars() -> Vec<String> {
|
||||
let mut keys: HashSet<String> = [
|
||||
"JCODE_HOME",
|
||||
"XDG_CONFIG_HOME",
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_OPENROUTER_PROVIDER_FEATURES",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_OPENROUTER_PROVIDER",
|
||||
"JCODE_OPENROUTER_NO_FALLBACK",
|
||||
"JCODE_OPENROUTER_MODEL",
|
||||
"JCODE_OPENROUTER_MODEL_CATALOG",
|
||||
"JCODE_OPENROUTER_STATIC_MODELS",
|
||||
"JCODE_OPENROUTER_AUTH_HEADER",
|
||||
"JCODE_OPENROUTER_AUTH_HEADER_NAME",
|
||||
"JCODE_OPENROUTER_DYNAMIC_BEARER_PROVIDER",
|
||||
"JCODE_OPENROUTER_THINKING",
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
"JCODE_OPENAI_COMPAT_API_KEY_NAME",
|
||||
"JCODE_OPENAI_COMPAT_ENV_FILE",
|
||||
"JCODE_OPENAI_COMPAT_SETUP_URL",
|
||||
"JCODE_OPENAI_COMPAT_DEFAULT_MODEL",
|
||||
"JCODE_OPENAI_COMPAT_LOCAL_ENABLED",
|
||||
"JCODE_NAMED_PROVIDER_PROFILE",
|
||||
"JCODE_PROVIDER_PROFILE_ACTIVE",
|
||||
"JCODE_PROVIDER_PROFILE_NAME",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"COPILOT_GITHUB_TOKEN",
|
||||
"GH_TOKEN",
|
||||
"GITHUB_TOKEN",
|
||||
"CURSOR_API_KEY",
|
||||
"GEMINI_API_KEY",
|
||||
]
|
||||
.into_iter()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
for profile in openai_compatible_profiles() {
|
||||
keys.insert(profile.api_key_env.to_string());
|
||||
}
|
||||
|
||||
let mut keys: Vec<_> = keys.into_iter().collect();
|
||||
keys.sort();
|
||||
keys
|
||||
}
|
||||
|
||||
struct TestEnv {
|
||||
_lock: MutexGuard<'static, ()>,
|
||||
saved: Vec<(String, Option<String>)>,
|
||||
temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl TestEnv {
|
||||
fn new() -> Result<Self> {
|
||||
let lock = lock_env();
|
||||
let temp = tempfile::Builder::new()
|
||||
.prefix("jcode-provider-matrix-")
|
||||
.tempdir()?;
|
||||
let saved = tracked_env_vars()
|
||||
.into_iter()
|
||||
.map(|key| {
|
||||
let value = std::env::var(&key).ok();
|
||||
(key, value)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (key, _) in &saved {
|
||||
jcode::env::remove_var(key);
|
||||
}
|
||||
|
||||
let config_root = temp.path().join("config").join("jcode");
|
||||
std::fs::create_dir_all(&config_root)?;
|
||||
jcode::env::set_var("JCODE_HOME", temp.path());
|
||||
jcode::config::invalidate_config_cache();
|
||||
apply_openai_compatible_profile_env(None);
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
Ok(Self {
|
||||
_lock: lock,
|
||||
saved,
|
||||
temp,
|
||||
})
|
||||
}
|
||||
|
||||
fn config_dir(&self) -> PathBuf {
|
||||
self.temp.path().join("config").join("jcode")
|
||||
}
|
||||
|
||||
fn config_file(&self) -> PathBuf {
|
||||
self.temp.path().join("config.toml")
|
||||
}
|
||||
|
||||
fn clear_profile_keys(&self) {
|
||||
jcode::env::remove_var("OPENROUTER_API_KEY");
|
||||
for profile in openai_compatible_profiles() {
|
||||
jcode::env::remove_var(profile.api_key_env);
|
||||
}
|
||||
AuthStatus::invalidate_cache();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestEnv {
|
||||
fn drop(&mut self) {
|
||||
apply_openai_compatible_profile_env(None);
|
||||
AuthStatus::invalidate_cache();
|
||||
jcode::config::invalidate_config_cache();
|
||||
for (key, value) in &self.saved {
|
||||
if let Some(value) = value {
|
||||
jcode::env::set_var(key, value);
|
||||
} else {
|
||||
jcode::env::remove_var(key);
|
||||
}
|
||||
}
|
||||
AuthStatus::invalidate_cache();
|
||||
jcode::config::invalidate_config_cache();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OpenAiCompatibleBaseState {
|
||||
DefaultRemote,
|
||||
SavedRemote,
|
||||
SavedLocal,
|
||||
}
|
||||
|
||||
impl OpenAiCompatibleBaseState {
|
||||
fn expected_api_base(self) -> &'static str {
|
||||
match self {
|
||||
Self::DefaultRemote => OPENAI_COMPAT_PROFILE.api_base,
|
||||
Self::SavedRemote => "https://state-space-openai-compatible.test/v1",
|
||||
Self::SavedLocal => "http://localhost:11434/v1",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::DefaultRemote => "default",
|
||||
Self::SavedRemote => "remote",
|
||||
Self::SavedLocal => "local",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_openai_compatible_runtime_env() {
|
||||
for key in [
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
"JCODE_OPENAI_COMPAT_API_KEY_NAME",
|
||||
"JCODE_OPENAI_COMPAT_ENV_FILE",
|
||||
"JCODE_OPENAI_COMPAT_SETUP_URL",
|
||||
"JCODE_OPENAI_COMPAT_DEFAULT_MODEL",
|
||||
"JCODE_OPENAI_COMPAT_LOCAL_ENABLED",
|
||||
"OPENAI_COMPAT_API_KEY",
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_OPENROUTER_PROVIDER_FEATURES",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_OPENROUTER_MODEL_CATALOG",
|
||||
"JCODE_OPENROUTER_MODEL",
|
||||
"JCODE_OPENROUTER_STATIC_MODELS",
|
||||
"JCODE_PROVIDER_PROFILE_ACTIVE",
|
||||
"JCODE_PROVIDER_PROFILE_NAME",
|
||||
"JCODE_NAMED_PROVIDER_PROFILE",
|
||||
] {
|
||||
jcode::env::remove_var(key);
|
||||
}
|
||||
AuthStatus::invalidate_cache();
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum CompetingCompatibleState {
|
||||
None,
|
||||
OtherSavedFiles,
|
||||
OtherSavedFilesAndConfigDefault,
|
||||
}
|
||||
|
||||
fn openai_compatible_login_providers(
|
||||
providers: impl IntoIterator<Item = LoginProviderDescriptor>,
|
||||
) -> Vec<LoginProviderDescriptor> {
|
||||
providers
|
||||
.into_iter()
|
||||
.filter(|provider| matches!(provider.target, LoginProviderTarget::OpenAiCompatible(_)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn non_compatible_login_providers(
|
||||
providers: impl IntoIterator<Item = LoginProviderDescriptor>,
|
||||
) -> Vec<LoginProviderDescriptor> {
|
||||
providers
|
||||
.into_iter()
|
||||
.filter(|provider| {
|
||||
!matches!(
|
||||
provider.target,
|
||||
LoginProviderTarget::OpenAiCompatible(_)
|
||||
| LoginProviderTarget::AutoImport
|
||||
| LoginProviderTarget::Google
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn login_provider_profile(provider: LoginProviderDescriptor) -> OpenAiCompatibleProfile {
|
||||
match provider.target {
|
||||
LoginProviderTarget::OpenAiCompatible(profile) => profile,
|
||||
_ => panic!("{} is not an OpenAI-compatible login provider", provider.id),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_profile_api_key_file(
|
||||
env: &TestEnv,
|
||||
profile: OpenAiCompatibleProfile,
|
||||
value: &str,
|
||||
) -> Result<()> {
|
||||
let resolved = resolve_openai_compatible_profile(profile);
|
||||
let path = env.config_dir().join(&resolved.env_file);
|
||||
std::fs::create_dir_all(env.config_dir())?;
|
||||
std::fs::write(&path, format!("{}={value}\n", resolved.api_key_env))?;
|
||||
jcode::env::remove_var(&resolved.api_key_env);
|
||||
AuthStatus::invalidate_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_profile_login_material(
|
||||
env: &TestEnv,
|
||||
profile: OpenAiCompatibleProfile,
|
||||
label: &str,
|
||||
) -> Result<()> {
|
||||
let resolved = resolve_openai_compatible_profile(profile);
|
||||
if resolved.requires_api_key {
|
||||
write_profile_api_key_file(env, profile, &format!("sk-{label}-{}", resolved.id))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn competing_remote_profiles(selected: OpenAiCompatibleProfile) -> Vec<OpenAiCompatibleProfile> {
|
||||
openai_compatible_profiles()
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|profile| profile.id != selected.id)
|
||||
.filter(|profile| resolve_openai_compatible_profile(*profile).requires_api_key)
|
||||
.take(2)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_competing_compatible_state(
|
||||
env: &TestEnv,
|
||||
selected: OpenAiCompatibleProfile,
|
||||
state: CompetingCompatibleState,
|
||||
) -> Result<()> {
|
||||
match state {
|
||||
CompetingCompatibleState::None => {}
|
||||
CompetingCompatibleState::OtherSavedFiles
|
||||
| CompetingCompatibleState::OtherSavedFilesAndConfigDefault => {
|
||||
let competitors = competing_remote_profiles(selected);
|
||||
assert!(
|
||||
!competitors.is_empty(),
|
||||
"provider catalog should have at least one competitor for {}",
|
||||
selected.id
|
||||
);
|
||||
for (index, competitor) in competitors.iter().copied().enumerate() {
|
||||
write_profile_api_key_file(
|
||||
env,
|
||||
competitor,
|
||||
&format!("sk-competing-{index}-{}", competitor.id),
|
||||
)?;
|
||||
}
|
||||
if matches!(
|
||||
state,
|
||||
CompetingCompatibleState::OtherSavedFilesAndConfigDefault
|
||||
) {
|
||||
let default_provider = competitors[0].id;
|
||||
std::fs::write(
|
||||
env.config_file(),
|
||||
format!("[provider]\ndefault_provider = \"{default_provider}\"\n"),
|
||||
)?;
|
||||
jcode::config::invalidate_config_cache();
|
||||
}
|
||||
}
|
||||
}
|
||||
AuthStatus::invalidate_cache();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn assert_runtime_profile_env(profile: OpenAiCompatibleProfile, context: &str) {
|
||||
let resolved = resolve_openai_compatible_profile(profile);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_BASE").ok().as_deref(),
|
||||
Some(resolved.api_base.as_str()),
|
||||
"runtime api base mismatch for {context}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some(resolved.api_key_env.as_str()),
|
||||
"runtime api key env mismatch for {context}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(),
|
||||
Some(resolved.env_file.as_str()),
|
||||
"runtime env file mismatch for {context}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_CACHE_NAMESPACE")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some(resolved.id.as_str()),
|
||||
"runtime cache namespace mismatch for {context}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ALLOW_NO_AUTH")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
(!resolved.requires_api_key).then_some("1"),
|
||||
"runtime no-auth flag mismatch for {context}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_no_compatible_runtime_profile_env(context: &str) {
|
||||
for key in [
|
||||
"JCODE_OPENROUTER_API_BASE",
|
||||
"JCODE_OPENROUTER_API_KEY_NAME",
|
||||
"JCODE_OPENROUTER_ENV_FILE",
|
||||
"JCODE_OPENROUTER_CACHE_NAMESPACE",
|
||||
"JCODE_OPENROUTER_PROVIDER_FEATURES",
|
||||
"JCODE_OPENROUTER_ALLOW_NO_AUTH",
|
||||
"JCODE_OPENROUTER_STATIC_MODELS",
|
||||
"JCODE_PROVIDER_PROFILE_ACTIVE",
|
||||
"JCODE_PROVIDER_PROFILE_NAME",
|
||||
"JCODE_NAMED_PROVIDER_PROFILE",
|
||||
] {
|
||||
assert!(
|
||||
std::env::var_os(key).is_none(),
|
||||
"{key} should be cleared for {context}; value={:?}",
|
||||
std::env::var_os(key)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_no_active_compatible_profile_lock(context: &str) {
|
||||
for key in [
|
||||
"JCODE_PROVIDER_PROFILE_ACTIVE",
|
||||
"JCODE_PROVIDER_PROFILE_NAME",
|
||||
] {
|
||||
assert!(
|
||||
std::env::var_os(key).is_none(),
|
||||
"{key} should not stay locked for {context}; value={:?}",
|
||||
std::env::var_os(key)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn seed_non_compatible_auto_auth(provider: LoginProviderDescriptor) -> bool {
|
||||
match provider.target {
|
||||
LoginProviderTarget::Claude => {
|
||||
jcode::env::set_var("ANTHROPIC_API_KEY", "test-anthropic-key");
|
||||
true
|
||||
}
|
||||
LoginProviderTarget::OpenAiApiKey => {
|
||||
jcode::env::set_var("OPENAI_API_KEY", "sk-test-openai-key");
|
||||
true
|
||||
}
|
||||
LoginProviderTarget::OpenRouter => {
|
||||
jcode::env::set_var("OPENROUTER_API_KEY", "sk-test-openrouter-key");
|
||||
true
|
||||
}
|
||||
LoginProviderTarget::Copilot => {
|
||||
jcode::env::set_var("COPILOT_GITHUB_TOKEN", "gho_test-copilot-token");
|
||||
true
|
||||
}
|
||||
LoginProviderTarget::Cursor => {
|
||||
jcode::env::set_var("CURSOR_API_KEY", "sk-test-cursor-key");
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_model_picker_has_profile_route(
|
||||
provider: &dyn Provider,
|
||||
profile: OpenAiCompatibleProfile,
|
||||
context: &str,
|
||||
) {
|
||||
let resolved = resolve_openai_compatible_profile(profile);
|
||||
let expected_api_method = format!("openai-compatible:{}", resolved.id);
|
||||
let routes = provider.model_routes();
|
||||
assert!(
|
||||
routes.iter().any(|route| {
|
||||
route.available
|
||||
&& route.api_method == expected_api_method
|
||||
&& !route.model.trim().is_empty()
|
||||
}),
|
||||
"model picker missing selected compatible provider route for {context}; expected_api_method={expected_api_method}, routes={routes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_matrix_bootstrap_login_profile_survives_auto_daemon_state_space() -> Result<()> {
|
||||
let providers = openai_compatible_login_providers(server_bootstrap_login_providers());
|
||||
assert!(
|
||||
!providers.is_empty(),
|
||||
"server bootstrap login surface should include compatible providers"
|
||||
);
|
||||
|
||||
for provider in providers {
|
||||
let selected = login_provider_profile(provider);
|
||||
for competing_state in [
|
||||
CompetingCompatibleState::None,
|
||||
CompetingCompatibleState::OtherSavedFiles,
|
||||
CompetingCompatibleState::OtherSavedFilesAndConfigDefault,
|
||||
] {
|
||||
let env = TestEnv::new()?;
|
||||
env.clear_profile_keys();
|
||||
write_profile_login_material(&env, selected, "selected-bootstrap")?;
|
||||
apply_competing_compatible_state(&env, selected, competing_state)?;
|
||||
|
||||
// Client-side bootstrap after a successful interactive login. The
|
||||
// actual daemon is spawned as `--provider auto`, so the selected
|
||||
// profile must survive the process boundary and auto-init path.
|
||||
apply_login_provider_profile_env(provider);
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
let context = format!(
|
||||
"bootstrap provider={} selected={} competing={competing_state:?}",
|
||||
provider.id, selected.id
|
||||
);
|
||||
assert_runtime_profile_env(selected, &context);
|
||||
|
||||
let runtime = init_provider_for_validation(&ProviderChoice::Auto, None)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("auto daemon init failed for {context}: {err}"));
|
||||
|
||||
assert_runtime_profile_env(selected, &context);
|
||||
assert_model_picker_has_profile_route(runtime.as_ref(), selected, &context);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_matrix_non_compatible_bootstrap_login_clears_stale_compatible_profile_state_space()
|
||||
-> Result<()> {
|
||||
let stale_provider = openai_compatible_login_providers(server_bootstrap_login_providers())
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("compatible bootstrap provider");
|
||||
let stale_profile = login_provider_profile(stale_provider);
|
||||
let providers = non_compatible_login_providers(server_bootstrap_login_providers());
|
||||
assert!(
|
||||
!providers.is_empty(),
|
||||
"server bootstrap login surface should include non-compatible providers"
|
||||
);
|
||||
|
||||
for provider in providers {
|
||||
let env = TestEnv::new()?;
|
||||
env.clear_profile_keys();
|
||||
write_profile_login_material(&env, stale_profile, "stale-non-compatible")?;
|
||||
apply_login_provider_profile_env(stale_provider);
|
||||
let context = format!(
|
||||
"non-compatible bootstrap provider={} stale_profile={}",
|
||||
provider.id, stale_profile.id
|
||||
);
|
||||
assert_runtime_profile_env(stale_profile, &context);
|
||||
|
||||
apply_login_provider_profile_env(provider);
|
||||
AuthStatus::invalidate_cache();
|
||||
assert_no_compatible_runtime_profile_env(&context);
|
||||
|
||||
if seed_non_compatible_auto_auth(provider) {
|
||||
AuthStatus::invalidate_cache();
|
||||
let runtime = init_provider_for_validation(&ProviderChoice::Auto, None)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("auto init failed for {context}: {err}"));
|
||||
// Auto-init may legitimately rediscover the stale compatible key file
|
||||
// as an additional available route. The bug-prone invariant is that a
|
||||
// non-compatible selection must clear the active compatible-profile
|
||||
// lock so it cannot force the daemon/model picker to stay on that
|
||||
// previous profile.
|
||||
assert_no_active_compatible_profile_lock(&context);
|
||||
assert!(
|
||||
!runtime.model_routes().is_empty(),
|
||||
"model picker routes should remain renderable for {context}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_matrix_concurrent_auto_init_preserves_bootstrap_compatible_profile() -> Result<()>
|
||||
{
|
||||
let providers = openai_compatible_login_providers(server_bootstrap_login_providers());
|
||||
assert!(!providers.is_empty(), "compatible bootstrap providers");
|
||||
|
||||
for provider in providers {
|
||||
let selected = login_provider_profile(provider);
|
||||
let env = TestEnv::new()?;
|
||||
env.clear_profile_keys();
|
||||
write_profile_login_material(&env, selected, "concurrent-auto")?;
|
||||
apply_login_provider_profile_env(provider);
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
let context = format!(
|
||||
"concurrent auto init provider={} selected={}",
|
||||
provider.id, selected.id
|
||||
);
|
||||
let (a, b, c, d) = tokio::join!(
|
||||
init_provider_for_validation(&ProviderChoice::Auto, None),
|
||||
init_provider_for_validation(&ProviderChoice::Auto, None),
|
||||
init_provider_for_validation(&ProviderChoice::Auto, None),
|
||||
init_provider_for_validation(&ProviderChoice::Auto, None),
|
||||
);
|
||||
for result in [a, b, c, d] {
|
||||
result.unwrap_or_else(|err| panic!("auto init failed for {context}: {err}"));
|
||||
}
|
||||
assert_runtime_profile_env(selected, &context);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_matrix_explicit_compatible_choice_overrides_stale_active_profile_state_space()
|
||||
-> Result<()> {
|
||||
let providers = openai_compatible_login_providers(login_providers().iter().copied());
|
||||
assert!(
|
||||
providers.len() > 1,
|
||||
"provider catalog should include multiple compatible providers"
|
||||
);
|
||||
|
||||
for provider in providers.iter().copied() {
|
||||
let selected = login_provider_profile(provider);
|
||||
let stale_provider = providers
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|candidate| login_provider_profile(*candidate).id != selected.id)
|
||||
.expect("stale compatible provider");
|
||||
let stale = login_provider_profile(stale_provider);
|
||||
let choice = choice_for_login_provider(provider)
|
||||
.unwrap_or_else(|| panic!("{} should map to a ProviderChoice", provider.id));
|
||||
|
||||
let env = TestEnv::new()?;
|
||||
env.clear_profile_keys();
|
||||
write_profile_login_material(&env, selected, "selected-direct")?;
|
||||
write_profile_login_material(&env, stale, "stale-direct")?;
|
||||
apply_competing_compatible_state(
|
||||
&env,
|
||||
selected,
|
||||
CompetingCompatibleState::OtherSavedFilesAndConfigDefault,
|
||||
)?;
|
||||
|
||||
apply_login_provider_profile_env(stale_provider);
|
||||
AuthStatus::invalidate_cache();
|
||||
let context = format!(
|
||||
"explicit choice={} selected={} stale_active={}",
|
||||
provider.id, selected.id, stale.id
|
||||
);
|
||||
assert_runtime_profile_env(stale, &context);
|
||||
|
||||
let runtime = init_provider_for_validation(&choice, None)
|
||||
.await
|
||||
.unwrap_or_else(|err| panic!("explicit compatible init failed for {context}: {err}"));
|
||||
|
||||
assert_runtime_profile_env(selected, &context);
|
||||
assert_model_picker_has_profile_route(runtime.as_ref(), selected, &context);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_matrix_openai_compatible_auth_state_space_material_states_preserve_login_invariants()
|
||||
-> Result<()> {
|
||||
let base_states = [
|
||||
OpenAiCompatibleBaseState::DefaultRemote,
|
||||
OpenAiCompatibleBaseState::SavedRemote,
|
||||
OpenAiCompatibleBaseState::SavedLocal,
|
||||
];
|
||||
|
||||
for base_state in base_states {
|
||||
for has_key in [false, true] {
|
||||
for has_default_model in [false, true] {
|
||||
for restarted in [false, true] {
|
||||
let env = TestEnv::new()?;
|
||||
env.clear_profile_keys();
|
||||
let state_label = format!(
|
||||
"base={base_state:?}, key={has_key}, default_model={has_default_model}, restarted={restarted}"
|
||||
);
|
||||
let model = format!(
|
||||
"state-space-{}-{}-{}-model",
|
||||
base_state.label(),
|
||||
if has_key { "key" } else { "nokey" },
|
||||
if restarted { "restart" } else { "hot" },
|
||||
);
|
||||
let env_file = OPENAI_COMPAT_PROFILE.env_file;
|
||||
|
||||
match base_state {
|
||||
OpenAiCompatibleBaseState::DefaultRemote => {}
|
||||
OpenAiCompatibleBaseState::SavedRemote
|
||||
| OpenAiCompatibleBaseState::SavedLocal => {
|
||||
save_env_value_to_env_file(
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
env_file,
|
||||
Some(base_state.expected_api_base()),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
if has_key {
|
||||
save_env_value_to_env_file(
|
||||
"OPENAI_COMPAT_API_KEY",
|
||||
env_file,
|
||||
Some("sk-state-space-login"),
|
||||
)?;
|
||||
}
|
||||
|
||||
if has_default_model {
|
||||
save_env_value_to_env_file(
|
||||
"JCODE_OPENAI_COMPAT_DEFAULT_MODEL",
|
||||
env_file,
|
||||
Some(&model),
|
||||
)?;
|
||||
}
|
||||
|
||||
if restarted {
|
||||
// Simulate a new process with only persisted login/config files.
|
||||
clear_openai_compatible_runtime_env();
|
||||
}
|
||||
|
||||
let resolved = resolve_openai_compatible_profile(OPENAI_COMPAT_PROFILE);
|
||||
assert_eq!(
|
||||
resolved.api_base,
|
||||
base_state.expected_api_base(),
|
||||
"api base mismatch for {state_label}"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.requires_api_key,
|
||||
base_state != OpenAiCompatibleBaseState::SavedLocal,
|
||||
"requires_api_key mismatch for {state_label}"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.default_model.as_deref(),
|
||||
has_default_model.then_some(model.as_str()),
|
||||
"default model mismatch for {state_label}"
|
||||
);
|
||||
|
||||
let loaded_key =
|
||||
load_api_key_from_env_or_config(&resolved.api_key_env, &resolved.env_file);
|
||||
assert_eq!(
|
||||
loaded_key.as_deref(),
|
||||
has_key.then_some("sk-state-space-login"),
|
||||
"saved key mismatch for {state_label}"
|
||||
);
|
||||
|
||||
let expected_configured =
|
||||
has_key || matches!(base_state, OpenAiCompatibleBaseState::SavedLocal);
|
||||
assert_eq!(
|
||||
openai_compatible_profile_is_configured(OPENAI_COMPAT_PROFILE),
|
||||
expected_configured,
|
||||
"configured predicate mismatch for {state_label}"
|
||||
);
|
||||
|
||||
apply_openai_compatible_profile_env(Some(OPENAI_COMPAT_PROFILE));
|
||||
AuthStatus::invalidate_cache();
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_BASE").ok().as_deref(),
|
||||
Some(resolved.api_base.as_str()),
|
||||
"runtime api base mismatch for {state_label}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some(resolved.api_key_env.as_str()),
|
||||
"runtime api key env mismatch for {state_label}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(),
|
||||
Some(resolved.env_file.as_str()),
|
||||
"runtime env file mismatch for {state_label}"
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ALLOW_NO_AUTH")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
(base_state == OpenAiCompatibleBaseState::SavedLocal).then_some("1"),
|
||||
"runtime no-auth flag mismatch for {state_label}"
|
||||
);
|
||||
assert_eq!(
|
||||
jcode::provider::openrouter::has_credentials(),
|
||||
expected_configured,
|
||||
"runtime credentials mismatch for {state_label}"
|
||||
);
|
||||
|
||||
let provider = OpenRouterProvider::new();
|
||||
if expected_configured {
|
||||
let provider = provider.unwrap_or_else(|err| {
|
||||
panic!("provider should construct for {state_label}: {err}")
|
||||
});
|
||||
provider.set_model(&model)?;
|
||||
assert_eq!(
|
||||
provider.model(),
|
||||
model,
|
||||
"selected model mismatch for {state_label}"
|
||||
);
|
||||
assert!(
|
||||
provider
|
||||
.available_models_display()
|
||||
.iter()
|
||||
.any(|available| available == &model),
|
||||
"configured model should be immediately visible for {state_label}"
|
||||
);
|
||||
let routes = provider.model_routes();
|
||||
assert!(
|
||||
routes.iter().any(|route| {
|
||||
route.provider == "OpenAI-compatible"
|
||||
&& route.api_method == "openai-compatible:openai-compatible"
|
||||
&& route.model == model
|
||||
&& route.available
|
||||
}),
|
||||
"configured model route should be immediately visible for {state_label}; routes: {routes:?}"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
provider.is_err(),
|
||||
"provider should not construct without credentials for {state_label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_matrix_env_credentials_activate_openrouter_runtime() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
|
||||
for &profile in openai_compatible_profiles() {
|
||||
env.clear_profile_keys();
|
||||
apply_openai_compatible_profile_env(Some(profile));
|
||||
let resolved = resolve_openai_compatible_profile(profile);
|
||||
jcode::env::set_var(&resolved.api_key_env, "matrix-env-secret");
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_BASE").ok().as_deref(),
|
||||
Some(resolved.api_base.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some(resolved.api_key_env.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(),
|
||||
Some(resolved.env_file.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_CACHE_NAMESPACE")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some(resolved.id.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_PROVIDER_FEATURES")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
assert!(
|
||||
jcode::provider::openrouter::has_credentials(),
|
||||
"expected credentials for {}",
|
||||
resolved.id
|
||||
);
|
||||
OpenRouterProvider::new()?;
|
||||
assert_eq!(AuthStatus::check().openrouter, AuthState::Available);
|
||||
|
||||
jcode::env::remove_var(&resolved.api_key_env);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_matrix_file_credentials_activate_openrouter_runtime() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
|
||||
for &profile in openai_compatible_profiles() {
|
||||
env.clear_profile_keys();
|
||||
apply_openai_compatible_profile_env(Some(profile));
|
||||
let resolved = resolve_openai_compatible_profile(profile);
|
||||
let env_file = env.config_dir().join(&resolved.env_file);
|
||||
std::fs::write(
|
||||
&env_file,
|
||||
format!("{}=matrix-file-secret\n", resolved.api_key_env),
|
||||
)?;
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
assert!(
|
||||
jcode::provider::openrouter::has_credentials(),
|
||||
"expected file credentials for {}",
|
||||
resolved.id
|
||||
);
|
||||
OpenRouterProvider::new()?;
|
||||
assert_eq!(AuthStatus::check().openrouter, AuthState::Available);
|
||||
|
||||
std::fs::remove_file(env_file)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_matrix_custom_compat_overrides_flow_into_runtime() -> Result<()> {
|
||||
let env = TestEnv::new()?;
|
||||
env.clear_profile_keys();
|
||||
|
||||
jcode::env::set_var(
|
||||
"JCODE_OPENAI_COMPAT_API_BASE",
|
||||
"https://api.groq.com/openai/v1/",
|
||||
);
|
||||
jcode::env::set_var("JCODE_OPENAI_COMPAT_API_KEY_NAME", "GROQ_API_KEY");
|
||||
jcode::env::set_var("JCODE_OPENAI_COMPAT_ENV_FILE", "groq.env");
|
||||
jcode::env::set_var("JCODE_OPENAI_COMPAT_DEFAULT_MODEL", "openai/gpt-oss-120b");
|
||||
|
||||
apply_openai_compatible_profile_env(Some(OPENAI_COMPAT_PROFILE));
|
||||
let resolved = resolve_openai_compatible_profile(OPENAI_COMPAT_PROFILE);
|
||||
let env_file = env.config_dir().join(&resolved.env_file);
|
||||
std::fs::write(
|
||||
&env_file,
|
||||
format!("{}=matrix-file-secret\n", resolved.api_key_env),
|
||||
)?;
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
assert_eq!(resolved.api_base, "https://api.groq.com/openai/v1");
|
||||
assert_eq!(resolved.api_key_env, "GROQ_API_KEY");
|
||||
assert_eq!(resolved.env_file, "groq.env");
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_BASE").ok().as_deref(),
|
||||
Some("https://api.groq.com/openai/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_API_KEY_NAME")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("GROQ_API_KEY")
|
||||
);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ENV_FILE").ok().as_deref(),
|
||||
Some("groq.env")
|
||||
);
|
||||
assert!(jcode::provider::openrouter::has_credentials());
|
||||
OpenRouterProvider::new()?;
|
||||
assert_eq!(AuthStatus::check().openrouter, AuthState::Available);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_matrix_custom_local_compat_without_api_key_activates_openrouter_runtime() -> Result<()>
|
||||
{
|
||||
let env = TestEnv::new()?;
|
||||
env.clear_profile_keys();
|
||||
|
||||
jcode::env::set_var("JCODE_OPENAI_COMPAT_API_BASE", "http://localhost:11434/v1");
|
||||
|
||||
apply_openai_compatible_profile_env(Some(OPENAI_COMPAT_PROFILE));
|
||||
let resolved = resolve_openai_compatible_profile(OPENAI_COMPAT_PROFILE);
|
||||
AuthStatus::invalidate_cache();
|
||||
|
||||
assert_eq!(resolved.api_base, "http://localhost:11434/v1");
|
||||
assert!(!resolved.requires_api_key);
|
||||
assert_eq!(
|
||||
std::env::var("JCODE_OPENROUTER_ALLOW_NO_AUTH")
|
||||
.ok()
|
||||
.as_deref(),
|
||||
Some("1")
|
||||
);
|
||||
assert!(jcode::provider::openrouter::has_credentials());
|
||||
OpenRouterProvider::new()?;
|
||||
assert_eq!(AuthStatus::check().openrouter, AuthState::Available);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify soft interrupt injection happens at the correct point.
|
||||
|
||||
This tests that:
|
||||
1. User messages are NOT injected between tool_use and tool_result
|
||||
2. User messages ARE injected after all tool_results are added
|
||||
3. The API doesn't return errors about tool_use/tool_result pairing
|
||||
|
||||
Run with: python tests/test_injection_fix.py
|
||||
"""
|
||||
|
||||
import socket
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
|
||||
import os
|
||||
|
||||
RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
SOCKET_PATH = os.path.join(RUNTIME_DIR, "jcode-debug.sock")
|
||||
|
||||
def send_cmd(sock, cmd, session_id=None, timeout=60):
|
||||
"""Send a debug command and get the response."""
|
||||
req = {"type": "debug_command", "id": 1, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
sock.send((json.dumps(req) + '\n').encode())
|
||||
sock.settimeout(timeout)
|
||||
data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
# Try to parse as complete JSON
|
||||
try:
|
||||
return json.loads(data.decode())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except socket.timeout:
|
||||
break
|
||||
return json.loads(data.decode()) if data else None
|
||||
|
||||
def test_injection_during_tools():
|
||||
"""Test that soft interrupts are injected AFTER tool results, not before."""
|
||||
print("=" * 60)
|
||||
print("Test: Soft interrupt injection during tool execution")
|
||||
print("=" * 60)
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(SOCKET_PATH)
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: Debug socket not found at {SOCKET_PATH}")
|
||||
print("Make sure jcode is running with debug control enabled.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create a test session
|
||||
print("\n1. Creating test session...")
|
||||
result = send_cmd(sock, "create_session:/tmp/injection-test")
|
||||
if not result or not result.get('ok'):
|
||||
print(f"Failed to create session: {result}")
|
||||
return False
|
||||
session_id = json.loads(result['output'])['session_id']
|
||||
print(f" Session ID: {session_id}")
|
||||
|
||||
# Send a message that will trigger tool use
|
||||
print("\n2. Sending message that triggers tool use...")
|
||||
result = send_cmd(sock, "message:Run the bash command 'echo hello'", session_id, timeout=120)
|
||||
if not result:
|
||||
print(" No response received")
|
||||
return False
|
||||
|
||||
if not result.get('ok'):
|
||||
error = result.get('error', 'Unknown error')
|
||||
if 'tool_use' in error and 'tool_result' in error:
|
||||
print(f" FAIL: API error about tool_use/tool_result pairing: {error}")
|
||||
return False
|
||||
print(f" Response: {error}")
|
||||
else:
|
||||
print(f" Response received (length: {len(result.get('output', ''))})")
|
||||
|
||||
# Check history to verify message order
|
||||
print("\n3. Checking history for correct message order...")
|
||||
result = send_cmd(sock, "history", session_id)
|
||||
if not result or not result.get('ok'):
|
||||
print(f"Failed to get history: {result}")
|
||||
return False
|
||||
|
||||
history = json.loads(result['output'])
|
||||
print(f" Found {len(history)} messages")
|
||||
|
||||
# Verify no user text message appears between tool_use and tool_result
|
||||
found_tool_use = False
|
||||
for i, msg in enumerate(history):
|
||||
role = msg.get('role', '')
|
||||
content = msg.get('content', '')
|
||||
|
||||
# Check for tool_use
|
||||
if role == 'assistant' and '[tool:' in content:
|
||||
found_tool_use = True
|
||||
print(f" [{i}] Assistant with tool_use")
|
||||
# Next message should be tool result, not user text
|
||||
if i + 1 < len(history):
|
||||
next_msg = history[i + 1]
|
||||
next_role = next_msg.get('role', '')
|
||||
next_content = next_msg.get('content', '')
|
||||
if next_role == 'user' and '[result:' not in next_content and 'tool' not in next_role:
|
||||
print(f" FAIL: User text message found between tool_use and tool_result")
|
||||
return False
|
||||
print(f" [{i+1}] {next_role}: {next_content[:50]}...")
|
||||
|
||||
if not found_tool_use:
|
||||
print(" (No tool calls found in this response)")
|
||||
|
||||
# Cleanup
|
||||
print("\n4. Cleaning up...")
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: No injection between tool_use and tool_result")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def test_injection_api_error():
|
||||
"""
|
||||
Reproduce the original bug: inject during tool execution and verify no API error.
|
||||
|
||||
The original error was:
|
||||
"messages.34: `tool_use` ids were found without `tool_result` blocks immediately after"
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test: Verify no API errors from injection timing")
|
||||
print("=" * 60)
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(SOCKET_PATH)
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: Debug socket not found at {SOCKET_PATH}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create session
|
||||
result = send_cmd(sock, "create_session:/tmp/api-error-test")
|
||||
if not result or not result.get('ok'):
|
||||
print(f"Failed to create session: {result}")
|
||||
return False
|
||||
session_id = json.loads(result['output'])['session_id']
|
||||
print(f" Session ID: {session_id}")
|
||||
|
||||
# Queue a soft interrupt
|
||||
print("\n1. Queueing soft interrupt...")
|
||||
result = send_cmd(sock, "queue_interrupt:This is an interrupt message", session_id)
|
||||
print(f" Result: {result.get('output', 'OK') if result else 'no response'}")
|
||||
|
||||
# Send a message that triggers multiple tool calls
|
||||
print("\n2. Sending message with tool calls...")
|
||||
result = send_cmd(sock,
|
||||
"message:Please run these three bash commands in sequence: echo one, echo two, echo three",
|
||||
session_id, timeout=180)
|
||||
|
||||
if not result:
|
||||
print(" No response")
|
||||
return False
|
||||
|
||||
if not result.get('ok'):
|
||||
error = result.get('error', '')
|
||||
if 'tool_use' in error.lower() and 'tool_result' in error.lower():
|
||||
print(f"\n FAIL: Got the original bug error!")
|
||||
print(f" Error: {error}")
|
||||
return False
|
||||
# Other errors might be OK (like tool not available, etc.)
|
||||
print(f" Response error (may be expected): {error[:100]}...")
|
||||
else:
|
||||
print(" Response received successfully")
|
||||
|
||||
# Cleanup
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: No API errors from injection timing")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def main():
|
||||
print("Soft Interrupt Injection Fix Tests")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Test 1: Check injection happens at correct point
|
||||
if not test_injection_during_tools():
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
|
||||
# Test 2: Verify no API errors
|
||||
if not test_injection_api_error():
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
if all_passed:
|
||||
print("✓ All tests passed!")
|
||||
return 0
|
||||
else:
|
||||
print("✗ Some tests failed")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,339 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Thorough testing of soft interrupt injection fix.
|
||||
|
||||
Tests that:
|
||||
1. With Claude provider: injected messages appear AFTER tool_results
|
||||
2. With OpenAI provider: injected messages appear AFTER tool_results
|
||||
3. Multiple tool calls: injection happens after ALL results
|
||||
4. Urgent interrupts: tool_results are added for skipped tools
|
||||
5. No API errors about tool_use/tool_result pairing
|
||||
|
||||
Run with: python tests/test_injection_thorough.py
|
||||
"""
|
||||
|
||||
import socket
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
|
||||
RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
SOCKET_PATH = os.path.join(RUNTIME_DIR, "jcode-debug.sock")
|
||||
|
||||
def send_cmd(sock, cmd, session_id=None, timeout=120):
|
||||
"""Send a debug command and get the response."""
|
||||
req = {"type": "debug_command", "id": 1, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
sock.send((json.dumps(req) + '\n').encode())
|
||||
sock.settimeout(timeout)
|
||||
data = b""
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
try:
|
||||
return json.loads(data.decode())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except socket.timeout:
|
||||
break
|
||||
return json.loads(data.decode()) if data else None
|
||||
|
||||
def check_history_order(history):
|
||||
"""
|
||||
Check that no user text message appears between tool_use and tool_result.
|
||||
Returns (is_valid, error_message)
|
||||
"""
|
||||
waiting_for_results = set() # tool_use IDs that need results
|
||||
|
||||
for i, msg in enumerate(history):
|
||||
role = msg.get('role', '')
|
||||
content = msg.get('content', '')
|
||||
|
||||
# Check for tool_use in assistant message
|
||||
if role == 'assistant':
|
||||
# Look for tool_use patterns like [tool: bash] or tool calls
|
||||
tool_matches = re.findall(r'\[tool: (\w+)\]', content)
|
||||
if tool_matches:
|
||||
for tool_name in tool_matches:
|
||||
waiting_for_results.add(f"tool_{i}_{tool_name}")
|
||||
|
||||
# Check for tool_result
|
||||
if role == 'tool' or (role == 'user' and '[result:' in content):
|
||||
# A tool result was found, clear one waiting
|
||||
if waiting_for_results:
|
||||
waiting_for_results.pop()
|
||||
|
||||
# Check for user text while waiting for results
|
||||
if role == 'user' and waiting_for_results:
|
||||
# Is this a tool result or actual user text?
|
||||
if '[result:' not in content and 'tool' not in content.lower():
|
||||
# This is user text between tool_use and tool_result!
|
||||
return False, f"User text '{content[:50]}...' found while waiting for tool_result for: {waiting_for_results}"
|
||||
|
||||
return True, None
|
||||
|
||||
def test_injection_with_provider(provider_name, session_id, sock):
|
||||
"""Test injection with a specific provider."""
|
||||
print(f"\n--- Testing with {provider_name} provider ---")
|
||||
|
||||
# Switch to the provider
|
||||
if provider_name == "openai":
|
||||
result = send_cmd(sock, "set_provider:openai", session_id)
|
||||
if not result or not result.get('ok'):
|
||||
print(f" Could not switch to OpenAI: {result}")
|
||||
print(f" Skipping OpenAI tests (may not be configured)")
|
||||
return True # Skip is not failure
|
||||
|
||||
# Queue a soft interrupt
|
||||
print("1. Queueing soft interrupt...")
|
||||
result = send_cmd(sock, "queue_interrupt:This is an interrupt during tools", session_id)
|
||||
if not result:
|
||||
print(" Failed to queue interrupt")
|
||||
return False
|
||||
print(f" Queued: {result.get('output', 'OK')}")
|
||||
|
||||
# Send a message that will trigger tool use
|
||||
print("2. Sending message that triggers tool use...")
|
||||
result = send_cmd(sock, "message:Run the bash command: echo 'hello from test'", session_id, timeout=180)
|
||||
|
||||
if not result:
|
||||
print(" No response (timeout)")
|
||||
return False
|
||||
|
||||
if not result.get('ok'):
|
||||
error = result.get('error', '')
|
||||
# Check for the specific error we're trying to prevent
|
||||
if 'tool_use' in error.lower() and 'tool_result' in error.lower():
|
||||
print(f" FAIL: Got tool_use/tool_result pairing error!")
|
||||
print(f" Error: {error}")
|
||||
return False
|
||||
print(f" Response error (may be expected): {error[:100]}...")
|
||||
|
||||
print(" Response received")
|
||||
|
||||
# Check history
|
||||
print("3. Checking message history order...")
|
||||
result = send_cmd(sock, "history", session_id)
|
||||
if not result or not result.get('ok'):
|
||||
print(f" Failed to get history: {result}")
|
||||
return False
|
||||
|
||||
history = json.loads(result['output'])
|
||||
print(f" Found {len(history)} messages")
|
||||
|
||||
is_valid, error_msg = check_history_order(history)
|
||||
if not is_valid:
|
||||
print(f" FAIL: {error_msg}")
|
||||
return False
|
||||
|
||||
print(f" ✓ History order is valid for {provider_name}")
|
||||
return True
|
||||
|
||||
def test_multiple_tools():
|
||||
"""Test injection when multiple tools are called."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test: Multiple tool calls")
|
||||
print("=" * 60)
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(SOCKET_PATH)
|
||||
except (FileNotFoundError, ConnectionRefusedError) as e:
|
||||
print(f"ERROR: Cannot connect to debug socket: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create session
|
||||
result = send_cmd(sock, "create_session:/tmp/multi-tool-test")
|
||||
if not result or not result.get('ok'):
|
||||
print(f"Failed to create session: {result}")
|
||||
return False
|
||||
session_id = json.loads(result['output'])['session_id']
|
||||
print(f"Session ID: {session_id}")
|
||||
|
||||
# Queue interrupt
|
||||
print("\n1. Queueing interrupt before multiple tool calls...")
|
||||
send_cmd(sock, "queue_interrupt:Interrupting during multiple tools", session_id)
|
||||
|
||||
# Request multiple tool calls
|
||||
print("2. Requesting multiple bash commands...")
|
||||
result = send_cmd(sock,
|
||||
"message:Please run these bash commands one at a time: echo first, echo second, echo third",
|
||||
session_id, timeout=180)
|
||||
|
||||
if result and not result.get('ok'):
|
||||
error = result.get('error', '')
|
||||
if 'tool_use' in error.lower() and 'tool_result' in error.lower():
|
||||
print(f" FAIL: Tool pairing error with multiple tools!")
|
||||
return False
|
||||
|
||||
# Check history
|
||||
print("3. Verifying history order...")
|
||||
result = send_cmd(sock, "history", session_id)
|
||||
if result and result.get('ok'):
|
||||
history = json.loads(result['output'])
|
||||
is_valid, error_msg = check_history_order(history)
|
||||
if not is_valid:
|
||||
print(f" FAIL: {error_msg}")
|
||||
return False
|
||||
print(" ✓ History order is valid")
|
||||
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: Multiple tool calls handled correctly")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def test_urgent_interrupt():
|
||||
"""Test urgent interrupt (should skip remaining tools with stub results)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test: Urgent interrupt")
|
||||
print("=" * 60)
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(SOCKET_PATH)
|
||||
except (FileNotFoundError, ConnectionRefusedError) as e:
|
||||
print(f"ERROR: Cannot connect to debug socket: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = send_cmd(sock, "create_session:/tmp/urgent-test")
|
||||
if not result or not result.get('ok'):
|
||||
return False
|
||||
session_id = json.loads(result['output'])['session_id']
|
||||
print(f"Session ID: {session_id}")
|
||||
|
||||
# Queue URGENT interrupt
|
||||
print("\n1. Queueing URGENT interrupt...")
|
||||
send_cmd(sock, "queue_interrupt_urgent:STOP! Cancel remaining tools!", session_id)
|
||||
|
||||
# Request tool calls
|
||||
print("2. Requesting tool calls...")
|
||||
result = send_cmd(sock,
|
||||
"message:Run these commands: echo a, echo b, echo c",
|
||||
session_id, timeout=180)
|
||||
|
||||
if result and not result.get('ok'):
|
||||
error = result.get('error', '')
|
||||
if 'tool_use' in error.lower() and 'tool_result' in error.lower():
|
||||
print(f" FAIL: Tool pairing error with urgent interrupt!")
|
||||
return False
|
||||
|
||||
# Check that skipped tools have results
|
||||
print("3. Checking that skipped tools have results...")
|
||||
result = send_cmd(sock, "history", session_id)
|
||||
if result and result.get('ok'):
|
||||
history = json.loads(result['output'])
|
||||
is_valid, error_msg = check_history_order(history)
|
||||
if not is_valid:
|
||||
print(f" FAIL: {error_msg}")
|
||||
return False
|
||||
|
||||
# Look for skip messages
|
||||
has_skip = any('skip' in str(msg.get('content', '')).lower() for msg in history)
|
||||
if has_skip:
|
||||
print(" ✓ Found skip message (tools were interrupted)")
|
||||
else:
|
||||
print(" (No skip message - tools may have completed before interrupt)")
|
||||
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: Urgent interrupt handled correctly")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def test_both_providers():
|
||||
"""Test injection with both Claude and OpenAI providers."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test: Both providers")
|
||||
print("=" * 60)
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(SOCKET_PATH)
|
||||
except (FileNotFoundError, ConnectionRefusedError) as e:
|
||||
print(f"ERROR: Cannot connect to debug socket: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = send_cmd(sock, "create_session:/tmp/provider-test")
|
||||
if not result or not result.get('ok'):
|
||||
return False
|
||||
session_id = json.loads(result['output'])['session_id']
|
||||
print(f"Session ID: {session_id}")
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Test Claude (default)
|
||||
if not test_injection_with_provider("claude", session_id, sock):
|
||||
all_passed = False
|
||||
|
||||
# Test OpenAI
|
||||
if not test_injection_with_provider("openai", session_id, sock):
|
||||
all_passed = False
|
||||
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
|
||||
if all_passed:
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: Both providers work correctly")
|
||||
print("=" * 60)
|
||||
return all_passed
|
||||
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def main():
|
||||
print("Thorough Soft Interrupt Injection Tests")
|
||||
print("=" * 60)
|
||||
print(f"Using socket: {SOCKET_PATH}")
|
||||
print()
|
||||
|
||||
# Check socket exists
|
||||
if not os.path.exists(SOCKET_PATH):
|
||||
print(f"ERROR: Socket not found at {SOCKET_PATH}")
|
||||
print("Make sure jcode is running with debug control enabled.")
|
||||
return 1
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Test 1: Multiple tool calls
|
||||
if not test_multiple_tools():
|
||||
all_passed = False
|
||||
|
||||
# Test 2: Urgent interrupt
|
||||
if not test_urgent_interrupt():
|
||||
all_passed = False
|
||||
|
||||
# Test 3: Both providers (if available)
|
||||
if not test_both_providers():
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
if all_passed:
|
||||
print("=" * 60)
|
||||
print("✓ ALL TESTS PASSED!")
|
||||
print("=" * 60)
|
||||
return 0
|
||||
else:
|
||||
print("=" * 60)
|
||||
print("✗ SOME TESTS FAILED")
|
||||
print("=" * 60)
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify selfdev reload works correctly.
|
||||
|
||||
This tests that:
|
||||
1. The selfdev reload tool returns appropriate output
|
||||
2. The reload context is saved
|
||||
3. After restart, the continuation message is sent to the model
|
||||
|
||||
Run with: python tests/test_selfdev_reload.py
|
||||
"""
|
||||
|
||||
import socket
|
||||
import json
|
||||
import time
|
||||
import os
|
||||
import sys
|
||||
import glob
|
||||
|
||||
RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
SOCKET_PATH = os.path.join(RUNTIME_DIR, "jcode-debug.sock")
|
||||
JCODE_DIR = os.path.expanduser("~/.jcode")
|
||||
|
||||
def send_cmd(sock, cmd, session_id=None, timeout=60):
|
||||
"""Send a debug command and get the response."""
|
||||
req = {"type": "debug_command", "id": 1, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
sock.send((json.dumps(req) + '\n').encode())
|
||||
sock.settimeout(timeout)
|
||||
data = b""
|
||||
while True:
|
||||
try:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
data += chunk
|
||||
try:
|
||||
return json.loads(data.decode())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except socket.timeout:
|
||||
break
|
||||
return json.loads(data.decode()) if data else None
|
||||
|
||||
def test_selfdev_status():
|
||||
"""Test that selfdev status works."""
|
||||
print("=" * 60)
|
||||
print("Test: selfdev status")
|
||||
print("=" * 60)
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(SOCKET_PATH)
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: Debug socket not found at {SOCKET_PATH}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create a test session
|
||||
result = send_cmd(sock, "create_session:selfdev:/home/jeremy/jcode")
|
||||
if not result or not result.get('ok'):
|
||||
print(f"Failed to create session: {result}")
|
||||
return False
|
||||
session_id = json.loads(result['output'])['session_id']
|
||||
print(f" Session ID: {session_id}")
|
||||
|
||||
# Check state to verify selfdev is available
|
||||
result = send_cmd(sock, "state", session_id)
|
||||
if result and result.get('ok'):
|
||||
state = json.loads(result['output'])
|
||||
print(f" Is canary: {state.get('is_canary', False)}")
|
||||
|
||||
# Call selfdev status
|
||||
print("\n1. Calling selfdev status...")
|
||||
result = send_cmd(sock, 'tool:selfdev {"action":"status"}', session_id, timeout=30)
|
||||
|
||||
if not result:
|
||||
print(" No response")
|
||||
return False
|
||||
|
||||
if result.get('ok'):
|
||||
output = result.get('output', '')
|
||||
print(f" Status output (preview):")
|
||||
for line in output.split('\n')[:10]:
|
||||
print(f" {line}")
|
||||
if len(output.split('\n')) > 10:
|
||||
print(f" ... ({len(output.split(chr(10)))} lines total)")
|
||||
else:
|
||||
error = result.get('error', 'Unknown error')
|
||||
if 'selfdev' in error.lower() and 'not available' in error.lower():
|
||||
print(f" SKIP: selfdev not available (not in self-dev mode)")
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
return True # Skip is not a failure
|
||||
print(f" Error: {error}")
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
return False
|
||||
|
||||
# Cleanup
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: selfdev status works")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def test_selfdev_socket_info():
|
||||
"""Test that selfdev socket-info works."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test: selfdev socket-info")
|
||||
print("=" * 60)
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.connect(SOCKET_PATH)
|
||||
except FileNotFoundError:
|
||||
print(f"ERROR: Debug socket not found")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = send_cmd(sock, "create_session:selfdev:/home/jeremy/jcode")
|
||||
if not result or not result.get('ok'):
|
||||
return False
|
||||
session_id = json.loads(result['output'])['session_id']
|
||||
|
||||
# Call selfdev socket-info
|
||||
print("1. Calling selfdev socket-info...")
|
||||
result = send_cmd(sock, 'tool:selfdev {"action":"socket-info"}', session_id, timeout=30)
|
||||
|
||||
if not result:
|
||||
print(" No response")
|
||||
return False
|
||||
|
||||
if result.get('ok'):
|
||||
output = result.get('output', '')
|
||||
print(f" Output (preview):")
|
||||
for line in output.split('\n')[:5]:
|
||||
print(f" {line}")
|
||||
|
||||
# Verify it contains expected info
|
||||
if 'debug_socket' in output.lower() or 'socket' in output.lower():
|
||||
print(" ✓ Contains socket info")
|
||||
else:
|
||||
print(" Warning: May not contain expected socket info")
|
||||
else:
|
||||
error = result.get('error', '')
|
||||
if 'not available' in error.lower():
|
||||
print(" SKIP: selfdev not available")
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
return True
|
||||
print(f" Error: {error}")
|
||||
return False
|
||||
|
||||
send_cmd(sock, f"destroy_session:{session_id}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: selfdev socket-info works")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
def test_reload_context():
|
||||
"""Test that reload context file exists and is valid JSON."""
|
||||
print("\n" + "=" * 60)
|
||||
print("Test: Reload context file format")
|
||||
print("=" * 60)
|
||||
|
||||
context_candidates = sorted(
|
||||
glob.glob(os.path.join(JCODE_DIR, "reload-context-*.json"))
|
||||
)
|
||||
legacy_context_path = os.path.join(JCODE_DIR, "reload-context.json")
|
||||
if os.path.exists(legacy_context_path):
|
||||
context_candidates.append(legacy_context_path)
|
||||
|
||||
# Check if there's an existing context file
|
||||
if context_candidates:
|
||||
context_path = context_candidates[0]
|
||||
print(f"1. Found existing reload context at {context_path}")
|
||||
try:
|
||||
with open(context_path) as f:
|
||||
ctx = json.load(f)
|
||||
print(f" Fields: {list(ctx.keys())}")
|
||||
|
||||
# Verify expected fields
|
||||
expected = ['task_context', 'version_before', 'version_after', 'session_id', 'timestamp']
|
||||
missing = [f for f in expected if f not in ctx]
|
||||
if missing:
|
||||
print(f" Warning: Missing fields: {missing}")
|
||||
else:
|
||||
print(" ✓ All expected fields present")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST PASSED: Reload context format is valid")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" ERROR: Invalid JSON: {e}")
|
||||
return False
|
||||
else:
|
||||
print(f"1. No existing reload context (will be created on selfdev reload)")
|
||||
print(" This is expected if no reload has been performed recently.")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST SKIPPED: No reload context to check")
|
||||
print("=" * 60)
|
||||
return True
|
||||
|
||||
def main():
|
||||
print("Selfdev Reload Tests")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
all_passed = True
|
||||
|
||||
# Test 1: selfdev status
|
||||
if not test_selfdev_status():
|
||||
all_passed = False
|
||||
|
||||
# Test 2: selfdev socket-info
|
||||
if not test_selfdev_socket_info():
|
||||
all_passed = False
|
||||
|
||||
# Test 3: Reload context format
|
||||
if not test_reload_context():
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
if all_passed:
|
||||
print("✓ All tests passed!")
|
||||
return 0
|
||||
else:
|
||||
print("✗ Some tests failed")
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||