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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+550
View File
@@ -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(())
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

+611
View File
@@ -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(())
}
+688
View File
@@ -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
}
+643
View File
@@ -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
}
+168
View File
@@ -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()
+176
View File
@@ -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())
+18
View File
@@ -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;
+124
View File
@@ -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(),
})
}
}
+947
View File
@@ -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(())
}
+331
View File
@@ -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
}
+134
View File
@@ -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"));
}
+308
View File
@@ -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(())
}
File diff suppressed because it is too large Load Diff
+64
View File
@@ -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(())
}
+329
View File
@@ -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}]}
+924
View File
@@ -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(())
}
+221
View File
@@ -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())
+339
View File
@@ -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())
+242
View File
@@ -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())