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
+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
}