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
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:
@@ -0,0 +1,611 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
/// Test ambient state: load, save, record_cycle
|
||||
#[test]
|
||||
fn test_ambient_state_lifecycle() {
|
||||
use jcode::ambient::{AmbientCycleResult, AmbientState, AmbientStatus, CycleStatus};
|
||||
|
||||
let mut state = AmbientState::default();
|
||||
assert!(matches!(state.status, AmbientStatus::Idle));
|
||||
assert_eq!(state.total_cycles, 0);
|
||||
assert!(state.last_run.is_none());
|
||||
|
||||
// Record a cycle
|
||||
let result = AmbientCycleResult {
|
||||
summary: "Gardened 3 memories".to_string(),
|
||||
memories_modified: 3,
|
||||
compactions: 0,
|
||||
proactive_work: None,
|
||||
next_schedule: None,
|
||||
started_at: chrono::Utc::now(),
|
||||
ended_at: chrono::Utc::now(),
|
||||
status: CycleStatus::Complete,
|
||||
conversation: None,
|
||||
};
|
||||
|
||||
state.record_cycle(&result);
|
||||
assert_eq!(state.total_cycles, 1);
|
||||
assert!(state.last_run.is_some());
|
||||
assert_eq!(state.last_summary.as_deref(), Some("Gardened 3 memories"));
|
||||
assert_eq!(state.last_memories_modified, Some(3));
|
||||
assert_eq!(state.last_compactions, Some(0));
|
||||
// No next_schedule → should be Idle
|
||||
assert!(matches!(state.status, AmbientStatus::Idle));
|
||||
}
|
||||
|
||||
/// Test ambient scheduled queue: push, pop, priority ordering
|
||||
#[test]
|
||||
fn test_ambient_scheduled_queue() {
|
||||
use jcode::ambient::{Priority, ScheduledItem, ScheduledQueue};
|
||||
|
||||
let tmp = std::env::temp_dir().join("jcode-test-queue.json");
|
||||
let _ = std::fs::remove_file(&tmp); // Clean up from previous runs
|
||||
let mut queue = ScheduledQueue::load(tmp);
|
||||
assert!(queue.is_empty());
|
||||
|
||||
// Push items with different priorities
|
||||
let now = chrono::Utc::now();
|
||||
queue.push(ScheduledItem {
|
||||
id: "low_1".to_string(),
|
||||
scheduled_for: now - chrono::Duration::minutes(5),
|
||||
context: "low priority task".to_string(),
|
||||
priority: Priority::Low,
|
||||
target: jcode::ambient::ScheduleTarget::Ambient,
|
||||
created_by_session: "test".to_string(),
|
||||
created_at: now,
|
||||
working_dir: None,
|
||||
task_description: None,
|
||||
relevant_files: Vec::new(),
|
||||
git_branch: None,
|
||||
additional_context: None,
|
||||
});
|
||||
|
||||
queue.push(ScheduledItem {
|
||||
id: "high_1".to_string(),
|
||||
scheduled_for: now - chrono::Duration::minutes(5),
|
||||
context: "high priority task".to_string(),
|
||||
priority: Priority::High,
|
||||
target: jcode::ambient::ScheduleTarget::Ambient,
|
||||
created_by_session: "test".to_string(),
|
||||
created_at: now,
|
||||
working_dir: None,
|
||||
task_description: None,
|
||||
relevant_files: Vec::new(),
|
||||
git_branch: None,
|
||||
additional_context: None,
|
||||
});
|
||||
|
||||
queue.push(ScheduledItem {
|
||||
id: "future_1".to_string(),
|
||||
scheduled_for: now + chrono::Duration::hours(1),
|
||||
context: "future task".to_string(),
|
||||
priority: Priority::Normal,
|
||||
target: jcode::ambient::ScheduleTarget::Ambient,
|
||||
created_by_session: "test".to_string(),
|
||||
created_at: now,
|
||||
working_dir: None,
|
||||
task_description: None,
|
||||
relevant_files: Vec::new(),
|
||||
git_branch: None,
|
||||
additional_context: None,
|
||||
});
|
||||
|
||||
assert_eq!(queue.len(), 3);
|
||||
|
||||
// Pop ready items: should get high priority first, then low (future not ready)
|
||||
let ready = queue.pop_ready();
|
||||
assert_eq!(ready.len(), 2);
|
||||
assert_eq!(ready[0].id, "high_1"); // High priority first
|
||||
assert_eq!(ready[1].id, "low_1"); // Low priority second
|
||||
|
||||
// Future item still in queue
|
||||
assert_eq!(queue.len(), 1);
|
||||
assert_eq!(queue.items()[0].id, "future_1");
|
||||
}
|
||||
|
||||
/// Test adaptive scheduler: interval calculation
|
||||
#[test]
|
||||
fn test_adaptive_scheduler_intervals() {
|
||||
use jcode::ambient_scheduler::{AdaptiveScheduler, AmbientSchedulerConfig};
|
||||
|
||||
let config = AmbientSchedulerConfig {
|
||||
min_interval_minutes: 5,
|
||||
max_interval_minutes: 120,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let scheduler = AdaptiveScheduler::new(config);
|
||||
|
||||
// With no rate limit info, should return max interval
|
||||
let interval = scheduler.calculate_interval(None);
|
||||
assert!(interval.as_secs() >= 120 * 60 - 1); // Allow 1s tolerance
|
||||
}
|
||||
|
||||
/// Test adaptive scheduler: backoff on rate limit
|
||||
#[test]
|
||||
fn test_adaptive_scheduler_backoff() {
|
||||
use jcode::ambient_scheduler::{AdaptiveScheduler, AmbientSchedulerConfig};
|
||||
|
||||
let config = AmbientSchedulerConfig {
|
||||
min_interval_minutes: 5,
|
||||
max_interval_minutes: 120,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut scheduler = AdaptiveScheduler::new(config);
|
||||
|
||||
let base_interval = scheduler.calculate_interval(None);
|
||||
|
||||
// Hit rate limit
|
||||
scheduler.on_rate_limit_hit();
|
||||
let backed_off = scheduler.calculate_interval(None);
|
||||
assert!(backed_off >= base_interval);
|
||||
|
||||
// Reset on success
|
||||
scheduler.on_successful_cycle();
|
||||
let after_reset = scheduler.calculate_interval(None);
|
||||
assert!(after_reset <= backed_off);
|
||||
}
|
||||
|
||||
/// Test adaptive scheduler: pause on active session
|
||||
#[test]
|
||||
fn test_adaptive_scheduler_pause() {
|
||||
use jcode::ambient_scheduler::{AdaptiveScheduler, AmbientSchedulerConfig};
|
||||
|
||||
let config = AmbientSchedulerConfig {
|
||||
min_interval_minutes: 5,
|
||||
max_interval_minutes: 120,
|
||||
pause_on_active_session: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut scheduler = AdaptiveScheduler::new(config);
|
||||
|
||||
assert!(!scheduler.should_pause());
|
||||
scheduler.set_user_active(true);
|
||||
assert!(scheduler.should_pause());
|
||||
scheduler.set_user_active(false);
|
||||
assert!(!scheduler.should_pause());
|
||||
}
|
||||
|
||||
/// Test ambient tools: end_ambient_cycle via mock agent
|
||||
#[tokio::test]
|
||||
async fn test_ambient_end_cycle_tool() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// Mock: agent calls end_ambient_cycle tool
|
||||
let tool_input = serde_json::json!({
|
||||
"summary": "Merged 2 duplicate memories, pruned 1 stale memory",
|
||||
"memories_modified": 3,
|
||||
"compactions": 0
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "tool_001".to_string(),
|
||||
name: "end_ambient_cycle".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(tool_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// After tool execution, the agent calls the provider again — mock a final response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Cycle complete.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Begin ambient cycle").await?;
|
||||
assert_eq!(response, "Cycle complete.");
|
||||
|
||||
// The tool should have stored a cycle result
|
||||
let result = jcode::tool::ambient::take_cycle_result();
|
||||
assert!(result.is_some());
|
||||
let result = result.unwrap();
|
||||
assert_eq!(
|
||||
result.summary,
|
||||
"Merged 2 duplicate memories, pruned 1 stale memory"
|
||||
);
|
||||
assert_eq!(result.memories_modified, 3);
|
||||
assert_eq!(result.compactions, 0);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test ambient tools: request_permission via mock agent
|
||||
#[tokio::test]
|
||||
async fn test_ambient_request_permission_tool() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
let tool_input = serde_json::json!({
|
||||
"action": "create_pull_request",
|
||||
"description": "Create PR for test fixes",
|
||||
"rationale": "Found 3 failing tests in auth module",
|
||||
"urgency": "high",
|
||||
"wait": false
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "tool_perm_001".to_string(),
|
||||
name: "request_permission".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(tool_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// After tool execution, mock a final response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Permission requested.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
let ambient_session_id = agent.session_id().to_string();
|
||||
jcode::tool::ambient::register_ambient_session(ambient_session_id.clone());
|
||||
|
||||
let response = agent.run_once_capture("Request permission").await?;
|
||||
jcode::tool::ambient::unregister_ambient_session(&ambient_session_id);
|
||||
assert_eq!(response, "Permission requested.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test ambient tools: schedule_ambient via mock agent
|
||||
#[tokio::test]
|
||||
async fn test_ambient_schedule_tool() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
let tool_input = serde_json::json!({
|
||||
"wake_in_minutes": 30,
|
||||
"context": "Check CI results and verify test fixes",
|
||||
"priority": "normal"
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "tool_sched_001".to_string(),
|
||||
name: "schedule_ambient".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(tool_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// After tool execution, mock a final response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Scheduled next cycle.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Schedule next cycle").await?;
|
||||
assert_eq!(response, "Scheduled next cycle.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test ambient system prompt builder
|
||||
#[test]
|
||||
fn test_ambient_system_prompt_builder() {
|
||||
use jcode::ambient::{
|
||||
AmbientState, MemoryGraphHealth, ResourceBudget, build_ambient_system_prompt,
|
||||
};
|
||||
|
||||
let state = AmbientState::default();
|
||||
let queue_items = vec![];
|
||||
let health = MemoryGraphHealth {
|
||||
total: 42,
|
||||
active: 38,
|
||||
inactive: 4,
|
||||
low_confidence: 2,
|
||||
contradictions: 1,
|
||||
missing_embeddings: 0,
|
||||
duplicate_candidates: 3,
|
||||
last_consolidation: None,
|
||||
};
|
||||
let recent_sessions = vec![];
|
||||
let feedback: Vec<String> = vec![];
|
||||
let budget = ResourceBudget {
|
||||
provider: "mock".to_string(),
|
||||
tokens_remaining_desc: "50k tokens".to_string(),
|
||||
window_resets_desc: "2h".to_string(),
|
||||
user_usage_rate_desc: "5k/min".to_string(),
|
||||
cycle_budget_desc: "stay under 50k".to_string(),
|
||||
};
|
||||
|
||||
let prompt = build_ambient_system_prompt(
|
||||
&state,
|
||||
&queue_items,
|
||||
&health,
|
||||
&recent_sessions,
|
||||
&feedback,
|
||||
&budget,
|
||||
0,
|
||||
);
|
||||
|
||||
// Verify key sections exist
|
||||
assert!(
|
||||
prompt.contains("ambient agent"),
|
||||
"Prompt missing 'ambient agent'"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Memory Graph Health"),
|
||||
"Prompt missing 'Memory Graph Health'"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Total memories: 42"),
|
||||
"Prompt missing memory count"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("Resource Budget"),
|
||||
"Prompt missing 'Resource Budget'"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("end_ambient_cycle"),
|
||||
"Prompt missing end_ambient_cycle instruction"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: status_json
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_status() {
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
let status_json = handle.status_json().await;
|
||||
let status: serde_json::Value = serde_json::from_str(&status_json).unwrap();
|
||||
|
||||
// Verify expected fields exist and have correct types
|
||||
assert!(status.get("status").is_some(), "Missing 'status' field");
|
||||
assert!(
|
||||
status.get("total_cycles").is_some(),
|
||||
"Missing 'total_cycles' field"
|
||||
);
|
||||
assert!(
|
||||
status.get("loop_running").is_some(),
|
||||
"Missing 'loop_running' field"
|
||||
);
|
||||
assert_eq!(
|
||||
status["loop_running"], false,
|
||||
"Runner loop should not be running"
|
||||
);
|
||||
assert!(
|
||||
status["total_cycles"].is_number(),
|
||||
"total_cycles should be a number"
|
||||
);
|
||||
assert!(
|
||||
status.get("queue_count").is_some(),
|
||||
"Missing 'queue_count' field"
|
||||
);
|
||||
assert!(
|
||||
status.get("active_user_sessions").is_some(),
|
||||
"Missing 'active_user_sessions' field"
|
||||
);
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: trigger and stop
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_trigger_and_stop() {
|
||||
use jcode::ambient::AmbientStatus;
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
// Stop (sets status to disabled)
|
||||
handle.stop().await;
|
||||
let state = handle.state().await;
|
||||
assert!(
|
||||
matches!(state.status, AmbientStatus::Disabled),
|
||||
"After stop(), status should be Disabled, got: {:?}",
|
||||
state.status
|
||||
);
|
||||
|
||||
// Runner should not be running (no loop was started)
|
||||
assert!(!handle.is_running().await, "Runner should not be active");
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: queue_json
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_queue_json() {
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
let json = handle.queue_json().await;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
}
|
||||
|
||||
/// Test ambient runner handle: log_json
|
||||
#[tokio::test]
|
||||
async fn test_ambient_runner_log_json() {
|
||||
use jcode::ambient_runner::AmbientRunnerHandle;
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = Arc::new(SafetySystem::new());
|
||||
let handle = AmbientRunnerHandle::new(safety);
|
||||
|
||||
let json = handle.log_json().await;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||
assert!(parsed.is_array());
|
||||
}
|
||||
|
||||
/// Test memory reinforcement provenance
|
||||
#[test]
|
||||
fn test_memory_reinforcement_provenance() {
|
||||
use jcode::memory::{MemoryCategory, MemoryEntry};
|
||||
|
||||
let mut entry = MemoryEntry::new(MemoryCategory::Preference, "User prefers dark mode");
|
||||
assert!(entry.reinforcements.is_empty());
|
||||
assert_eq!(entry.strength, 1); // Initial strength
|
||||
|
||||
// Reinforce with provenance
|
||||
entry.reinforce("session_abc123", 42);
|
||||
assert_eq!(entry.strength, 2);
|
||||
assert_eq!(entry.reinforcements.len(), 1);
|
||||
assert_eq!(entry.reinforcements[0].session_id, "session_abc123");
|
||||
assert_eq!(entry.reinforcements[0].message_index, 42);
|
||||
|
||||
// Reinforce again from different session
|
||||
entry.reinforce("session_def456", 10);
|
||||
assert_eq!(entry.strength, 3);
|
||||
assert_eq!(entry.reinforcements.len(), 2);
|
||||
assert_eq!(entry.reinforcements[1].session_id, "session_def456");
|
||||
assert_eq!(entry.reinforcements[1].message_index, 10);
|
||||
}
|
||||
|
||||
/// Test ambient config defaults
|
||||
#[test]
|
||||
fn test_ambient_config_defaults() {
|
||||
use jcode::config::AmbientConfig;
|
||||
|
||||
let config = AmbientConfig::default();
|
||||
assert!(!config.enabled);
|
||||
assert!(!config.allow_api_keys);
|
||||
assert_eq!(config.min_interval_minutes, 5);
|
||||
assert_eq!(config.max_interval_minutes, 120);
|
||||
assert!(config.pause_on_active_session);
|
||||
assert!(config.proactive_work);
|
||||
assert_eq!(config.work_branch_prefix, "ambient/");
|
||||
assert!(config.provider.is_none());
|
||||
assert!(config.model.is_none());
|
||||
assert!(config.api_daily_budget.is_none());
|
||||
}
|
||||
|
||||
/// Test ambient lock acquisition and release
|
||||
#[test]
|
||||
fn test_ambient_lock() {
|
||||
use jcode::ambient::AmbientLock;
|
||||
let _env = setup_test_env().expect("failed to setup isolated JCODE_HOME");
|
||||
|
||||
// First acquisition should succeed
|
||||
let lock1 = AmbientLock::try_acquire();
|
||||
assert!(lock1.is_ok());
|
||||
let lock1 = lock1.unwrap();
|
||||
assert!(lock1.is_some());
|
||||
let lock1 = lock1.unwrap();
|
||||
|
||||
// Second acquisition should fail (lock held)
|
||||
let lock2 = AmbientLock::try_acquire();
|
||||
assert!(lock2.is_ok());
|
||||
assert!(lock2.unwrap().is_none());
|
||||
|
||||
// Release
|
||||
let _ = lock1.release();
|
||||
|
||||
// Now should succeed again
|
||||
let lock3 = AmbientLock::try_acquire();
|
||||
assert!(lock3.is_ok());
|
||||
assert!(lock3.unwrap().is_some());
|
||||
}
|
||||
|
||||
/// Test full ambient cycle simulation with mock provider
|
||||
/// Simulates: agent receives prompt → uses tools → calls end_ambient_cycle
|
||||
#[tokio::test]
|
||||
async fn test_full_ambient_cycle_simulation() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// Turn 1: Agent calls end_ambient_cycle with full data
|
||||
let end_cycle_input = serde_json::json!({
|
||||
"summary": "Gardened memory graph: merged 2 duplicates about dark mode preference, pruned 1 stale memory with confidence 0.02, verified 5 facts against codebase.",
|
||||
"memories_modified": 6,
|
||||
"compactions": 1,
|
||||
"proactive_work": null,
|
||||
"next_schedule": {
|
||||
"wake_in_minutes": 45,
|
||||
"context": "Follow up on memory verification",
|
||||
"priority": "normal"
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Starting ambient cycle...\n".to_string()),
|
||||
StreamEvent::ToolUseStart {
|
||||
id: "call_end".to_string(),
|
||||
name: "end_ambient_cycle".to_string(),
|
||||
},
|
||||
StreamEvent::ToolInputDelta(end_cycle_input),
|
||||
StreamEvent::ToolUseEnd,
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
// Turn 2: After end_ambient_cycle tool result, agent responds
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Ambient cycle completed successfully.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
registry.register_ambient_tools().await;
|
||||
|
||||
let mut agent = Agent::new(provider.clone(), registry);
|
||||
agent.set_system_prompt("You are the jcode ambient maintenance agent.");
|
||||
|
||||
let response = agent.run_once_capture("Begin your ambient cycle.").await?;
|
||||
|
||||
assert!(response.contains("Ambient cycle completed"));
|
||||
|
||||
// Verify end_ambient_cycle stored the result
|
||||
let result = jcode::tool::ambient::take_cycle_result();
|
||||
assert!(result.is_some());
|
||||
let result = result.unwrap();
|
||||
assert_eq!(result.memories_modified, 6);
|
||||
assert_eq!(result.compactions, 1);
|
||||
assert!(result.summary.contains("Gardened memory graph"));
|
||||
assert!(result.next_schedule.is_some());
|
||||
let sched = result.next_schedule.unwrap();
|
||||
assert_eq!(sched.wake_in_minutes, Some(45));
|
||||
assert!(sched.context.contains("Follow up"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
// ============================================================================
|
||||
// Binary Integration Tests
|
||||
// These tests run the actual jcode binary and require real credentials.
|
||||
// Run with: cargo test --test e2e binary_integration -- --ignored
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Reload/handoff robustness coverage map (for future contributors)
|
||||
//
|
||||
// Unit-level (no credentials, run by default):
|
||||
// - server::reload_state::tests + server::socket_tests: marker/handoff state
|
||||
// machine (Ready/Waiting/Failed/Idle verdicts, dead-pid crash detection,
|
||||
// stale/foreign/completed marker cleanup, Failed-marker preservation,
|
||||
// corrupt-marker tolerance, bounded handoff-event wait).
|
||||
// - server::reload::reload_tests: graceful shutdown signaling, timeout, and
|
||||
// partial-checkpoint behavior; recovery-intent persistence for peers.
|
||||
// - server::reload_recovery::tests: recovery-store path-traversal safety,
|
||||
// persist/peek roundtrip, non-consuming directive peek, delivery
|
||||
// idempotency + continuation mismatch.
|
||||
// - server::util::reload_target_tests: no-downgrade exec-target guard.
|
||||
//
|
||||
// E2E (real spawned process, run with --ignored; need a release binary):
|
||||
// - binary_integration_reload_handoff: server identity changes, marker clears.
|
||||
// - binary_integration_selfdev_reload_reconnects_quickly: repeated reloads.
|
||||
// - binary_integration_selfdev_client_reload_resumes_session.
|
||||
// - binary_integration_selfdev_full_reload_resumes_session_quickly.
|
||||
//
|
||||
// Known E2E gaps worth adding when a release binary is available:
|
||||
// - Concurrent/rapid `client.reload()` calls collapsing into one handoff
|
||||
// without stranding the client or leaving a stuck marker.
|
||||
// - A pre-existing *foreign* stale reload marker (different pid) in the
|
||||
// runtime dir at boot being cleared rather than blocking startup.
|
||||
// - Crash-during-boot of the replacement server (e.g. point the reload
|
||||
// candidate at a binary that exits non-zero) resolving the waiting client
|
||||
// to a Failed verdict instead of an indefinite hang.
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/// Test that the jcode binary can run independent with Claude provider
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires Claude credentials
|
||||
async fn binary_integration_independent_claude() -> Result<()> {
|
||||
use std::process::Command;
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let output = Command::new("cargo")
|
||||
.args([
|
||||
"run",
|
||||
"--release",
|
||||
"--bin",
|
||||
"jcode",
|
||||
"--",
|
||||
"run",
|
||||
"Say 'test-ok' and nothing else",
|
||||
])
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
output.status.success() || stdout.contains("test") || stderr.contains("Claude"),
|
||||
"Binary should run successfully. stdout: {}, stderr: {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that the jcode binary can run with OpenAI provider
|
||||
#[tokio::test]
|
||||
#[ignore] // Requires OpenAI/Codex credentials
|
||||
async fn binary_integration_openai_provider() -> Result<()> {
|
||||
use std::process::Command;
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let output = Command::new("cargo")
|
||||
.args([
|
||||
"run",
|
||||
"--release",
|
||||
"--bin",
|
||||
"jcode",
|
||||
"--",
|
||||
"--provider",
|
||||
"openai",
|
||||
"run",
|
||||
"Say 'openai-ok' and nothing else",
|
||||
])
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Check either success or identifiable OpenAI response
|
||||
let has_response = stdout.to_lowercase().contains("openai")
|
||||
|| stdout.to_lowercase().contains("ok")
|
||||
|| stderr.contains("OpenAI");
|
||||
|
||||
assert!(
|
||||
output.status.success() || has_response,
|
||||
"OpenAI provider should work. stdout: {}, stderr: {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that jcode version command works
|
||||
#[tokio::test]
|
||||
async fn binary_version_command() -> Result<()> {
|
||||
use std::process::Command;
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_jcode"))
|
||||
.arg("--version")
|
||||
.output()?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
assert!(output.status.success(), "Version command should succeed");
|
||||
assert!(
|
||||
stdout.contains("jcode") || stdout.contains("20"),
|
||||
"Version should contain 'jcode' or date. Got: {}",
|
||||
stdout
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test full server reload handoff against a real spawned server process.
|
||||
///
|
||||
/// Requires a built release binary at target/release/jcode because the reload
|
||||
/// flow execs into the repo's reload candidate.
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_reload_handoff() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
let stderr_path = temp_root.path().join("server-stderr.log");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let stderr_file = std::fs::File::create(&stderr_path)?;
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_jcode"))
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&socket_path)
|
||||
.arg("serve")
|
||||
// This test must exercise the real exec-based reload handoff, not the
|
||||
// in-process test shortcut used by other e2e cases.
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir)
|
||||
.env("JCODE_DEBUG_CONTROL", "1")
|
||||
.env("JCODE_TEMP_SERVER", "1")
|
||||
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.spawn()?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id before reload"))?
|
||||
.to_string();
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
client.reload().await?;
|
||||
|
||||
let disconnect_deadline = Instant::now() + Duration::from_secs(10);
|
||||
let mut saw_disconnect = false;
|
||||
while Instant::now() < disconnect_deadline {
|
||||
match tokio::time::timeout(Duration::from_secs(1), client.read_event()).await {
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) | Err(_) => {
|
||||
saw_disconnect = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_disconnect,
|
||||
"old client connection never disconnected during reload"
|
||||
);
|
||||
|
||||
let marker_deadline = Instant::now() + Duration::from_secs(20);
|
||||
while jcode::server::reload_marker_active(Duration::from_secs(30)) {
|
||||
if Instant::now() >= marker_deadline {
|
||||
anyhow::bail!("reload marker remained active too long after restart");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let _client = wait_for_server_client(&socket_path).await?;
|
||||
|
||||
let server_info_after =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_after_json: serde_json::Value = serde_json::from_str(&server_info_after)?;
|
||||
let server_id_after = server_info_after_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id after reload"))?;
|
||||
|
||||
assert_ne!(
|
||||
server_id_after, server_id_before,
|
||||
"server identity should change after exec-based reload"
|
||||
);
|
||||
assert!(
|
||||
server_info_after_json
|
||||
.get("uptime_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.is_some(),
|
||||
"replacement server should answer debug state queries after reload"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
kill_child(&mut child);
|
||||
if let Err(ref error) = test_result {
|
||||
if let Ok(stderr) = std::fs::read_to_string(&stderr_path) {
|
||||
eprintln!("spawned server stderr:\n{}", stderr);
|
||||
}
|
||||
eprintln!("reload e2e test error: {error:#}");
|
||||
}
|
||||
test_result
|
||||
}
|
||||
|
||||
/// Test repeated self-dev reload handoff against a real TUI client running in a PTY.
|
||||
///
|
||||
/// Requires a built release binary at target/release/jcode because the
|
||||
/// self-dev server reload path execs into the repo's reload candidate.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_selfdev_reload_reconnects_quickly() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-selfdev-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
|
||||
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
|
||||
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
let mut command = Command::new(&release_binary);
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--provider")
|
||||
.arg("antigravity")
|
||||
.arg("self-dev")
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let mut child = spawn_pty_child(command)?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
|
||||
|
||||
let state_before =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", None).await?;
|
||||
let _: serde_json::Value = serde_json::from_str(&state_before)?;
|
||||
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let mut server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing initial server id"))?
|
||||
.to_string();
|
||||
|
||||
for cycle in 1..=3 {
|
||||
child.send_command("/server-reload")?;
|
||||
|
||||
let server_id_after = wait_for_selfdev_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&server_id_before,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
assert_ne!(
|
||||
server_id_after, server_id_before,
|
||||
"self-dev reload cycle {} should replace the server process",
|
||||
cycle
|
||||
);
|
||||
server_id_before = server_id_after;
|
||||
}
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
debug_run_command(debug_socket_path.clone(), "client:quit", None),
|
||||
)
|
||||
.await;
|
||||
kill_child(&mut child.child);
|
||||
|
||||
if let Err(ref error) = test_result {
|
||||
eprintln!("self-dev reload e2e test error: {error:#}");
|
||||
eprintln!("self-dev client PTY output:\n{}", child.output_text());
|
||||
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
|
||||
eprintln!("self-dev reload logs (tail):\n{}", log_excerpt);
|
||||
}
|
||||
}
|
||||
|
||||
test_result
|
||||
}
|
||||
|
||||
/// Test self-dev client binary reload against a real TUI client running in a PTY.
|
||||
///
|
||||
/// Starts from the test binary, then forces `/client-reload` to re-exec into
|
||||
/// the built release candidate while keeping the shared server online.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_selfdev_client_reload_resumes_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-selfdev-client-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
|
||||
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
|
||||
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
let starter_binary = temp_root.path().join("jcode-selfdev-client-starter");
|
||||
std::fs::copy(env!("CARGO_BIN_EXE_jcode"), &starter_binary)?;
|
||||
let starter_mtime = std::fs::metadata(&release_binary)?
|
||||
.modified()?
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.unwrap_or(std::time::UNIX_EPOCH + Duration::from_secs(1));
|
||||
set_file_mtime(&starter_binary, starter_mtime)?;
|
||||
|
||||
let mut command = Command::new(&starter_binary);
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--provider")
|
||||
.arg("antigravity")
|
||||
.arg("self-dev")
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let mut child = spawn_pty_child(command)?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
|
||||
|
||||
let state_before =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_before_json: serde_json::Value = serde_json::from_str(&state_before)?;
|
||||
let version_before = state_before_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version before reload"))?
|
||||
.to_string();
|
||||
|
||||
let clients_before =
|
||||
debug_run_command(debug_socket_path.clone(), "clients:map", None).await?;
|
||||
let clients_before_json: serde_json::Value = serde_json::from_str(&clients_before)?;
|
||||
let client_id_before = clients_before_json
|
||||
.get("clients")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|clients| {
|
||||
clients.iter().find_map(|client| {
|
||||
let session = client.get("session_id").and_then(|v| v.as_str())?;
|
||||
if session != session_id {
|
||||
return None;
|
||||
}
|
||||
client
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client id before reload"))?;
|
||||
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id before client reload"))?
|
||||
.to_string();
|
||||
|
||||
child.send_command("/client-reload")?;
|
||||
|
||||
let client_id_after = wait_for_selfdev_client_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&client_id_before,
|
||||
&server_id_before,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let state_after =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_after_json: serde_json::Value = serde_json::from_str(&state_after)?;
|
||||
let version_after = state_after_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version after reload"))?;
|
||||
|
||||
assert_ne!(
|
||||
client_id_after, client_id_before,
|
||||
"client reload should reconnect with a different client id"
|
||||
);
|
||||
assert_ne!(
|
||||
version_after, version_before,
|
||||
"client reload should switch binaries"
|
||||
);
|
||||
|
||||
let server_info_after =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_after_json: serde_json::Value = serde_json::from_str(&server_info_after)?;
|
||||
let server_id_after = server_info_after_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id after client reload"))?;
|
||||
assert_eq!(
|
||||
server_id_after, server_id_before,
|
||||
"client reload should not replace the server process"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
debug_run_command(debug_socket_path.clone(), "client:quit", None),
|
||||
)
|
||||
.await;
|
||||
kill_child(&mut child.child);
|
||||
|
||||
if let Err(ref error) = test_result {
|
||||
eprintln!("self-dev client reload e2e test error: {error:#}");
|
||||
eprintln!("self-dev client PTY output:\n{}", child.output_text());
|
||||
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
|
||||
eprintln!("self-dev client reload logs (tail):\n{}", log_excerpt);
|
||||
}
|
||||
}
|
||||
|
||||
test_result
|
||||
}
|
||||
|
||||
/// Test full self-dev `/reload` against a real TUI client running in a PTY.
|
||||
///
|
||||
/// Starts from an older starter binary so the client reloads into the built
|
||||
/// release candidate while the shared server also restarts.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn binary_integration_selfdev_full_reload_resumes_session_quickly() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let release_binary =
|
||||
jcode::build::release_binary_path(std::path::Path::new(env!("CARGO_MANIFEST_DIR")));
|
||||
if !release_binary.exists() {
|
||||
anyhow::bail!(
|
||||
"release binary missing at {} (run `cargo build --release` first)",
|
||||
release_binary.display()
|
||||
);
|
||||
}
|
||||
|
||||
let temp_root = tempfile::Builder::new()
|
||||
.prefix("jcode-selfdev-full-reload-e2e-")
|
||||
.tempdir()?;
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let _home_guard = EnvVarGuard::set("JCODE_HOME", &home_dir);
|
||||
let _runtime_guard = EnvVarGuard::set("JCODE_RUNTIME_DIR", &runtime_dir);
|
||||
let _install_guard = EnvVarGuard::set("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
let starter_binary = temp_root.path().join("jcode-selfdev-full-reload-starter");
|
||||
std::fs::copy(env!("CARGO_BIN_EXE_jcode"), &starter_binary)?;
|
||||
let starter_mtime = std::fs::metadata(&release_binary)?
|
||||
.modified()?
|
||||
.checked_sub(Duration::from_secs(60))
|
||||
.unwrap_or(std::time::UNIX_EPOCH + Duration::from_secs(1));
|
||||
set_file_mtime(&starter_binary, starter_mtime)?;
|
||||
|
||||
let mut command = Command::new(&starter_binary);
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--provider")
|
||||
.arg("antigravity")
|
||||
.arg("self-dev")
|
||||
.current_dir(env!("CARGO_MANIFEST_DIR"))
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir);
|
||||
|
||||
let mut child = spawn_pty_child(command)?;
|
||||
|
||||
let test_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = wait_for_default_connected_client_session(&debug_socket_path).await?;
|
||||
|
||||
let state_before =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_before_json: serde_json::Value = serde_json::from_str(&state_before)?;
|
||||
let version_before = state_before_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version before full reload"))?
|
||||
.to_string();
|
||||
|
||||
let clients_before =
|
||||
debug_run_command(debug_socket_path.clone(), "clients:map", None).await?;
|
||||
let clients_before_json: serde_json::Value = serde_json::from_str(&clients_before)?;
|
||||
let client_id_before = clients_before_json
|
||||
.get("clients")
|
||||
.and_then(|v| v.as_array())
|
||||
.and_then(|clients| {
|
||||
clients.iter().find_map(|client| {
|
||||
let session = client.get("session_id").and_then(|v| v.as_str())?;
|
||||
if session != session_id {
|
||||
return None;
|
||||
}
|
||||
client
|
||||
.get("client_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client id before full reload"))?;
|
||||
|
||||
let server_info_before =
|
||||
debug_run_command(debug_socket_path.clone(), "server:info", None).await?;
|
||||
let server_info_before_json: serde_json::Value = serde_json::from_str(&server_info_before)?;
|
||||
let server_id_before = server_info_before_json
|
||||
.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing server id before full reload"))?
|
||||
.to_string();
|
||||
|
||||
child.send_command("/reload")?;
|
||||
|
||||
let server_id_after = wait_for_selfdev_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&server_id_before,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client_id_after = wait_for_selfdev_client_reload_cycle(
|
||||
&debug_socket_path,
|
||||
&session_id,
|
||||
&client_id_before,
|
||||
&server_id_after,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let state_after =
|
||||
debug_run_command(debug_socket_path.clone(), "client:state", Some(&session_id)).await?;
|
||||
let state_after_json: serde_json::Value = serde_json::from_str(&state_after)?;
|
||||
let version_after = state_after_json
|
||||
.get("version")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("missing client version after full reload"))?;
|
||||
|
||||
assert_ne!(
|
||||
server_id_after, server_id_before,
|
||||
"full reload should replace the server process"
|
||||
);
|
||||
assert_ne!(
|
||||
client_id_after, client_id_before,
|
||||
"full reload should reconnect with a different client id"
|
||||
);
|
||||
assert_ne!(
|
||||
version_after, version_before,
|
||||
"full reload should switch binaries"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
let _ = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
debug_run_command(debug_socket_path.clone(), "client:quit", None),
|
||||
)
|
||||
.await;
|
||||
kill_child(&mut child.child);
|
||||
|
||||
if let Err(ref error) = test_result {
|
||||
eprintln!("self-dev full reload e2e test error: {error:#}");
|
||||
eprintln!("self-dev client PTY output:\n{}", child.output_text());
|
||||
if let Some(log_excerpt) = latest_log_excerpt(&home_dir) {
|
||||
eprintln!("self-dev full reload logs (tail):\n{}", log_excerpt);
|
||||
}
|
||||
}
|
||||
|
||||
test_result
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
use crate::test_support::*;
|
||||
use futures::future::join_all;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct BurstAttachClientMetrics {
|
||||
target_session_id: String,
|
||||
returned_session_id: String,
|
||||
attach_ms: u128,
|
||||
history_message_count: usize,
|
||||
provider_model: Option<String>,
|
||||
event_count: usize,
|
||||
ack_count: usize,
|
||||
history_count: usize,
|
||||
done_count: usize,
|
||||
other_count: usize,
|
||||
}
|
||||
|
||||
enum BurstAttachOutcome {
|
||||
Attached(BurstAttachClientMetrics),
|
||||
Rejected(String),
|
||||
}
|
||||
|
||||
async fn burst_attach_resumed_client(
|
||||
socket_path: PathBuf,
|
||||
target_session_id: String,
|
||||
) -> Result<(server::Client, BurstAttachClientMetrics)> {
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_start = Instant::now();
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(None, None, Some(target_session_id.clone()), false, false)
|
||||
.await?;
|
||||
|
||||
let mut event_count = 0usize;
|
||||
let mut ack_count = 0usize;
|
||||
let mut history_count = 0usize;
|
||||
let mut done_count = 0usize;
|
||||
let mut other_count = 0usize;
|
||||
let mut returned_session_id = None;
|
||||
let mut history_message_count = 0usize;
|
||||
let mut provider_model = None;
|
||||
|
||||
// Hosted CI runners intermittently stall a single socket read past 1s when
|
||||
// many burst clients contend for the runtime. Tolerate per-read timeouts and
|
||||
// only bound the total attach wait, mirroring the deflake applied elsewhere.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let event = match timeout(Duration::from_secs(1), client.read_event()).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => continue,
|
||||
};
|
||||
event_count += 1;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => ack_count += 1,
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
messages,
|
||||
provider_model: event_provider_model,
|
||||
..
|
||||
} if id == subscribe_id => {
|
||||
history_count += 1;
|
||||
returned_session_id = Some(session_id);
|
||||
history_message_count = messages.len();
|
||||
provider_model = event_provider_model;
|
||||
}
|
||||
ServerEvent::Done { id } if id == subscribe_id => {
|
||||
done_count += 1;
|
||||
let metrics = BurstAttachClientMetrics {
|
||||
target_session_id,
|
||||
returned_session_id: returned_session_id
|
||||
.ok_or_else(|| anyhow::anyhow!("missing subscribe history event"))?,
|
||||
attach_ms: subscribe_start.elapsed().as_millis(),
|
||||
history_message_count,
|
||||
provider_model,
|
||||
event_count,
|
||||
ack_count,
|
||||
history_count,
|
||||
done_count,
|
||||
other_count,
|
||||
};
|
||||
return Ok((client, metrics));
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == subscribe_id => {
|
||||
anyhow::bail!("subscribe failed for {}: {}", target_session_id, message);
|
||||
}
|
||||
_ => other_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"timed out attaching resumed client to {} after {} events",
|
||||
target_session_id,
|
||||
event_count
|
||||
)
|
||||
}
|
||||
|
||||
async fn burst_attach_resumed_client_with_options(
|
||||
socket_path: PathBuf,
|
||||
target_session_id: String,
|
||||
client_has_local_history: bool,
|
||||
allow_session_takeover: bool,
|
||||
) -> Result<BurstAttachOutcome> {
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_start = Instant::now();
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(
|
||||
None,
|
||||
None,
|
||||
Some(target_session_id.clone()),
|
||||
client_has_local_history,
|
||||
allow_session_takeover,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut event_count = 0usize;
|
||||
let mut ack_count = 0usize;
|
||||
let mut history_count = 0usize;
|
||||
let mut done_count = 0usize;
|
||||
let mut other_count = 0usize;
|
||||
let mut returned_session_id = None;
|
||||
let mut history_message_count = 0usize;
|
||||
let mut provider_model = None;
|
||||
|
||||
// Hosted CI runners intermittently stall a single socket read past 1s when
|
||||
// many burst clients contend for the runtime. Tolerate per-read timeouts and
|
||||
// only bound the total attach wait, mirroring the deflake applied elsewhere.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let event = match timeout(Duration::from_secs(1), client.read_event()).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => continue,
|
||||
};
|
||||
event_count += 1;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => ack_count += 1,
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
messages,
|
||||
provider_model: event_provider_model,
|
||||
..
|
||||
} if id == subscribe_id => {
|
||||
history_count += 1;
|
||||
returned_session_id = Some(session_id);
|
||||
history_message_count = messages.len();
|
||||
provider_model = event_provider_model;
|
||||
}
|
||||
ServerEvent::Done { id } if id == subscribe_id => {
|
||||
done_count += 1;
|
||||
let metrics = BurstAttachClientMetrics {
|
||||
target_session_id,
|
||||
returned_session_id: returned_session_id
|
||||
.ok_or_else(|| anyhow::anyhow!("missing subscribe history event"))?,
|
||||
attach_ms: subscribe_start.elapsed().as_millis(),
|
||||
history_message_count,
|
||||
provider_model,
|
||||
event_count,
|
||||
ack_count,
|
||||
history_count,
|
||||
done_count,
|
||||
other_count,
|
||||
};
|
||||
drop(client);
|
||||
return Ok(BurstAttachOutcome::Attached(metrics));
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == subscribe_id => {
|
||||
return Ok(BurstAttachOutcome::Rejected(message));
|
||||
}
|
||||
_ => other_count += 1,
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"timed out attaching resumed client to {} after {} events",
|
||||
target_session_id,
|
||||
event_count
|
||||
)
|
||||
}
|
||||
|
||||
async fn run_burst_resume_attach_stress(burst_size: usize) -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-burst-spawn-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let unique_suffix = runtime_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("burst");
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let mut expected_session_ids = Vec::with_capacity(burst_size);
|
||||
for idx in 0..burst_size {
|
||||
let mut session = Session::create_with_id(
|
||||
format!("session_burst_attach_{idx}_{unique_suffix}"),
|
||||
None,
|
||||
Some(format!("Burst Attach {idx}")),
|
||||
);
|
||||
session.model = Some("burst-model".to_string());
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("resume me {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("attached reply {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
expected_session_ids.push(session.id);
|
||||
}
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["burst-model"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let cpu_start = current_process_cpu_time()?;
|
||||
let wall_start = Instant::now();
|
||||
|
||||
let burst_results = join_all(expected_session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move { burst_attach_resumed_client(socket_path, session_id.to_string()).await }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut connected_clients = Vec::with_capacity(burst_size);
|
||||
let mut metrics = Vec::with_capacity(burst_size);
|
||||
for result in burst_results {
|
||||
let (client, client_metrics) = result?;
|
||||
assert_eq!(
|
||||
client_metrics.returned_session_id,
|
||||
client_metrics.target_session_id
|
||||
);
|
||||
assert_eq!(client_metrics.history_count, 1);
|
||||
assert_eq!(client_metrics.done_count, 1);
|
||||
assert!(
|
||||
client_metrics.history_message_count >= 2,
|
||||
"expected resumed history for {} to include persisted messages",
|
||||
client_metrics.target_session_id
|
||||
);
|
||||
assert_eq!(
|
||||
client_metrics.provider_model.as_deref(),
|
||||
Some("burst-model")
|
||||
);
|
||||
connected_clients.push(client);
|
||||
metrics.push(client_metrics);
|
||||
}
|
||||
|
||||
let wall_elapsed = wall_start.elapsed();
|
||||
let cpu_elapsed = current_process_cpu_time()?.saturating_sub(cpu_start);
|
||||
|
||||
let client_map = debug_run_command_json(debug_socket_path.clone(), "clients:map", None).await?;
|
||||
let info = debug_run_command_json(debug_socket_path.clone(), "server:info", None).await?;
|
||||
|
||||
let clients = client_map
|
||||
.get("clients")
|
||||
.and_then(|value| value.as_array())
|
||||
.context("clients:map missing clients array")?;
|
||||
assert_eq!(
|
||||
client_map.get("count").and_then(|value| value.as_u64()),
|
||||
Some(burst_size as u64)
|
||||
);
|
||||
assert_eq!(clients.len(), burst_size);
|
||||
|
||||
let mapped_session_ids: HashSet<String> = clients
|
||||
.iter()
|
||||
.filter_map(|client| {
|
||||
client
|
||||
.get("session_id")
|
||||
.and_then(|value| value.as_str())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.collect();
|
||||
let expected_session_ids_set: HashSet<String> = expected_session_ids.iter().cloned().collect();
|
||||
assert_eq!(mapped_session_ids, expected_session_ids_set);
|
||||
|
||||
let ready_count = clients
|
||||
.iter()
|
||||
.filter(|client| client.get("status").and_then(|value| value.as_str()) == Some("ready"))
|
||||
.count();
|
||||
assert_eq!(
|
||||
ready_count, burst_size,
|
||||
"all resumed clients should settle to ready"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
info.get("session_count").and_then(|value| value.as_u64()),
|
||||
Some(burst_size as u64)
|
||||
);
|
||||
assert_eq!(
|
||||
info.get("swarm_member_count")
|
||||
.and_then(|value| value.as_u64()),
|
||||
Some(burst_size as u64),
|
||||
"burst attach should not leak temporary swarm members"
|
||||
);
|
||||
|
||||
let mut latencies_ms: Vec<u128> = metrics.iter().map(|metric| metric.attach_ms).collect();
|
||||
latencies_ms.sort_unstable();
|
||||
let total_events: usize = metrics.iter().map(|metric| metric.event_count).sum();
|
||||
let total_acks: usize = metrics.iter().map(|metric| metric.ack_count).sum();
|
||||
let total_histories: usize = metrics.iter().map(|metric| metric.history_count).sum();
|
||||
let total_dones: usize = metrics.iter().map(|metric| metric.done_count).sum();
|
||||
let total_other_events: usize = metrics.iter().map(|metric| metric.other_count).sum();
|
||||
let total_history_messages: usize = metrics
|
||||
.iter()
|
||||
.map(|metric| metric.history_message_count)
|
||||
.sum();
|
||||
let cpu_utilization = if wall_elapsed.is_zero() {
|
||||
0.0
|
||||
} else {
|
||||
cpu_elapsed.as_secs_f64() / wall_elapsed.as_secs_f64()
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"burst_spawn_metrics={} ",
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"burst_size": burst_size,
|
||||
"wall_ms": wall_elapsed.as_millis(),
|
||||
"cpu_ms": cpu_elapsed.as_millis(),
|
||||
"cpu_utilization_ratio": cpu_utilization,
|
||||
"cpu_ms_per_attach": cpu_elapsed.as_secs_f64() * 1000.0 / burst_size as f64,
|
||||
"latency_ms": {
|
||||
"min": latencies_ms.first().copied().unwrap_or(0),
|
||||
"p50": percentile_ms(&latencies_ms, 50),
|
||||
"p90": percentile_ms(&latencies_ms, 90),
|
||||
"p99": percentile_ms(&latencies_ms, 99),
|
||||
"max": latencies_ms.last().copied().unwrap_or(0),
|
||||
"spread": latencies_ms.last().copied().unwrap_or(0)
|
||||
.saturating_sub(latencies_ms.first().copied().unwrap_or(0)),
|
||||
},
|
||||
"events": {
|
||||
"total": total_events,
|
||||
"acks": total_acks,
|
||||
"histories": total_histories,
|
||||
"dones": total_dones,
|
||||
"other": total_other_events,
|
||||
},
|
||||
"history_messages_total": total_history_messages,
|
||||
"connected_clients": clients.len(),
|
||||
"unique_session_mappings": mapped_session_ids.len(),
|
||||
"ready_count": ready_count,
|
||||
"server_info": info,
|
||||
}))?
|
||||
);
|
||||
|
||||
drop(connected_clients);
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn burst_retry_takeover_without_local_history_keeps_existing_live_clients_connected()
|
||||
-> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-burst-spawn-live-clients-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let unique_suffix = runtime_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("burst-live");
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let live_session_count = 10usize;
|
||||
let mut live_session_ids = Vec::with_capacity(live_session_count);
|
||||
for idx in 0..live_session_count {
|
||||
let mut session = Session::create_with_id(
|
||||
format!("session_live_attach_{idx}_{unique_suffix}"),
|
||||
None,
|
||||
Some(format!("Live Attach {idx}")),
|
||||
);
|
||||
session.model = Some("burst-model".to_string());
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("resume me live {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("live reply {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
live_session_ids.push(session.id);
|
||||
}
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["burst-model"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut live_clients = Vec::with_capacity(live_session_count);
|
||||
for session_id in live_session_ids.iter().cloned() {
|
||||
let (client, metrics) =
|
||||
burst_attach_resumed_client(socket_path.clone(), session_id.clone()).await?;
|
||||
assert_eq!(metrics.returned_session_id, session_id);
|
||||
live_clients.push((session_id, client));
|
||||
}
|
||||
|
||||
// A client is `Done` (from its own perspective) slightly before the server
|
||||
// finishes registering it in `clients:map`. Wait until all live clients are
|
||||
// registered AND the map is stable across two reads before snapshotting it;
|
||||
// otherwise a retry burst can race a not-yet-settled live registration and
|
||||
// legitimately-but-nondeterministically take it over, flaking the assertion
|
||||
// below. (Was an intermittent CI failure under load.)
|
||||
wait_for_debug_client_count(
|
||||
&debug_socket_path,
|
||||
live_session_count,
|
||||
Duration::from_secs(10),
|
||||
)
|
||||
.await?;
|
||||
let initial_session_to_client = {
|
||||
let mut previous: Option<std::collections::HashMap<String, String>> = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let snapshot = client_id_map(
|
||||
&debug_run_command_json(debug_socket_path.clone(), "clients:map", None).await?,
|
||||
)?;
|
||||
if snapshot.len() == live_session_count && previous.as_ref() == Some(&snapshot) {
|
||||
break snapshot;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break snapshot;
|
||||
}
|
||||
previous = Some(snapshot);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
};
|
||||
|
||||
let retry_results = join_all(live_session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move {
|
||||
burst_attach_resumed_client_with_options(
|
||||
socket_path,
|
||||
session_id.to_string(),
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
|
||||
for result in retry_results {
|
||||
match result? {
|
||||
BurstAttachOutcome::Attached(metrics) => {
|
||||
assert_eq!(metrics.returned_session_id, metrics.target_session_id);
|
||||
assert_eq!(metrics.history_count, 1);
|
||||
assert_eq!(metrics.done_count, 1);
|
||||
}
|
||||
BurstAttachOutcome::Rejected(message) => {
|
||||
anyhow::bail!("retry attach should succeed for live shared session: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each retry attaches as a short-lived observer and then drops, so after
|
||||
// the burst the server asynchronously drains those extra connections. The
|
||||
// invariant we actually care about is that none of the *original* live
|
||||
// client connections were superseded. Wait for the connection set to settle
|
||||
// back to exactly the live clients, then compare the set of live client ids.
|
||||
// (Comparing a session->client_id map directly is ill-defined here because
|
||||
// multiple connections can momentarily share a session id and the lossy map
|
||||
// collapses them, which flaked under CI load.)
|
||||
let initial_client_ids: std::collections::BTreeSet<String> =
|
||||
initial_session_to_client.values().cloned().collect();
|
||||
wait_for_debug_client_count(
|
||||
&debug_socket_path,
|
||||
live_session_count,
|
||||
Duration::from_secs(15),
|
||||
)
|
||||
.await?;
|
||||
let final_client_ids = {
|
||||
let mut previous: Option<std::collections::BTreeSet<String>> = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let snapshot: std::collections::BTreeSet<String> = client_id_map(
|
||||
&debug_run_command_json(debug_socket_path.clone(), "clients:map", None).await?,
|
||||
)?
|
||||
.into_values()
|
||||
.collect();
|
||||
if snapshot.len() == live_session_count && previous.as_ref() == Some(&snapshot) {
|
||||
break snapshot;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
break snapshot;
|
||||
}
|
||||
previous = Some(snapshot);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
};
|
||||
assert_eq!(
|
||||
final_client_ids, initial_client_ids,
|
||||
"existing live client connections should not be replaced during burst retries"
|
||||
);
|
||||
|
||||
drop(live_clients);
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stress the burst attach path used when many spawned windows resume pre-created sessions.
|
||||
/// This targets the race-prone phase directly and records useful metrics for regressions.
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn burst_spawn_resume_attach_keeps_unique_live_mappings_and_reports_metrics() -> Result<()> {
|
||||
run_burst_resume_attach_stress(20).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
async fn burst_attach_detach_reattach_restores_live_clients_cleanly() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-burst-spawn-reattach-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let unique_suffix = runtime_dir
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("burst-reattach");
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let burst_size = 6usize;
|
||||
let mut session_ids = Vec::with_capacity(burst_size);
|
||||
for idx in 0..burst_size {
|
||||
let mut session = Session::create_with_id(
|
||||
format!("session_burst_reattach_{idx}_{unique_suffix}"),
|
||||
None,
|
||||
Some(format!("Burst Reattach {idx}")),
|
||||
);
|
||||
session.model = Some("burst-model".to_string());
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("resume me reattach {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: format!("reattach reply {idx}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
session_ids.push(session.id);
|
||||
}
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["burst-model"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let initial_results = join_all(session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move { burst_attach_resumed_client(socket_path, session_id.to_string()).await }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut initial_clients = Vec::with_capacity(burst_size);
|
||||
for result in initial_results {
|
||||
let (client, metrics) = result?;
|
||||
assert_eq!(metrics.returned_session_id, metrics.target_session_id);
|
||||
initial_clients.push(client);
|
||||
}
|
||||
wait_for_debug_client_count(&debug_socket_path, burst_size, Duration::from_secs(5)).await?;
|
||||
|
||||
drop(initial_clients);
|
||||
wait_for_debug_client_count(&debug_socket_path, 0, Duration::from_secs(5)).await?;
|
||||
|
||||
let reattach_results = join_all(session_ids.iter().map(|session_id| {
|
||||
let socket_path = socket_path.clone();
|
||||
async move { burst_attach_resumed_client(socket_path, session_id.to_string()).await }
|
||||
}))
|
||||
.await;
|
||||
|
||||
let mut reattached_clients = Vec::with_capacity(burst_size);
|
||||
for result in reattach_results {
|
||||
let (client, metrics) = result?;
|
||||
assert_eq!(metrics.returned_session_id, metrics.target_session_id);
|
||||
assert_eq!(metrics.history_count, 1);
|
||||
assert_eq!(metrics.done_count, 1);
|
||||
reattached_clients.push(client);
|
||||
}
|
||||
wait_for_debug_client_count(&debug_socket_path, burst_size, Duration::from_secs(5)).await?;
|
||||
|
||||
drop(reattached_clients);
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 8)]
|
||||
#[ignore = "resource-heavy scale validation"]
|
||||
async fn burst_spawn_resume_attach_scales_to_100_clients() -> Result<()> {
|
||||
run_burst_resume_attach_stress(100).await
|
||||
}
|
||||
Executable
+168
@@ -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()
|
||||
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless E2E regression for the edit expand badge shortcut.
|
||||
|
||||
This starts a real jcode TUI client inside a pseudo-terminal, prepares a real
|
||||
rendered edit-diff expand-badge fixture through the client debug command, sends
|
||||
terminal key bytes to the PTY, and asserts the live TUI state changed.
|
||||
|
||||
Run from repo root after building jcode:
|
||||
python3 tests/e2e/expand_badge_headless.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RUNTIME_DIR = os.environ.get("XDG_RUNTIME_DIR") or f"/run/user/{os.getuid()}"
|
||||
SOCKET_PATH = os.path.join(RUNTIME_DIR, "jcode-debug.sock")
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def send_cmd(sock, cmd, session_id=None, timeout=10):
|
||||
req = {"type": "debug_command", "id": int(time.time() * 1000) % 1000000, "command": cmd}
|
||||
if session_id:
|
||||
req["session_id"] = session_id
|
||||
sock.sendall((json.dumps(req) + "\n").encode())
|
||||
sock.settimeout(timeout)
|
||||
data = b""
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
raise RuntimeError("debug socket closed")
|
||||
data += chunk
|
||||
try:
|
||||
return json.loads(data.decode())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=10, interval=0.1, desc="condition"):
|
||||
deadline = time.time() + timeout
|
||||
last = None
|
||||
while time.time() < deadline:
|
||||
last = predicate()
|
||||
if last:
|
||||
return last
|
||||
time.sleep(interval)
|
||||
raise AssertionError(f"timed out waiting for {desc}; last={last!r}")
|
||||
|
||||
|
||||
def drain_pty(master_fd):
|
||||
chunks = []
|
||||
while True:
|
||||
r, _, _ = select.select([master_fd], [], [], 0)
|
||||
if not r:
|
||||
break
|
||||
try:
|
||||
chunks.append(os.read(master_fd, 65536))
|
||||
except OSError:
|
||||
break
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def main():
|
||||
binary = os.environ.get("JCODE_E2E_BIN")
|
||||
if not binary:
|
||||
candidates = [
|
||||
REPO / "target" / "selfdev" / "jcode",
|
||||
Path.home() / ".jcode" / "builds" / "current" / "jcode",
|
||||
]
|
||||
binary = next((str(p) for p in candidates if p.exists()), None)
|
||||
if not binary:
|
||||
raise SystemExit("No jcode binary found. Set JCODE_E2E_BIN or build first.")
|
||||
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.connect(SOCKET_PATH)
|
||||
|
||||
session_id = None
|
||||
proc = None
|
||||
master_fd = None
|
||||
try:
|
||||
res = send_cmd(sock, f"create_session:selfdev:{REPO}")
|
||||
if not res.get("ok"):
|
||||
raise AssertionError(f"create_session failed: {res}")
|
||||
session_id = json.loads(res["output"])["session_id"]
|
||||
print(f"session={session_id}")
|
||||
|
||||
master_fd, slave_fd = pty.openpty()
|
||||
env = os.environ.copy()
|
||||
env.setdefault("TERM", "xterm-kitty")
|
||||
env.setdefault("JCODE_CLIENT_SELFDEV_MODE", "1")
|
||||
proc = subprocess.Popen(
|
||||
[binary, "self-dev", "--resume", session_id],
|
||||
stdin=slave_fd,
|
||||
stdout=slave_fd,
|
||||
stderr=slave_fd,
|
||||
cwd=str(REPO),
|
||||
env=env,
|
||||
start_new_session=True,
|
||||
)
|
||||
os.close(slave_fd)
|
||||
|
||||
def client_attached():
|
||||
out = send_cmd(sock, "clients:map", timeout=5)
|
||||
clients = json.loads(out["output"])["clients"] if isinstance(out.get("output"), str) else out.get("clients", [])
|
||||
return any(c.get("session_id") == session_id for c in clients)
|
||||
|
||||
wait_for(client_attached, timeout=15, desc="TUI client attach")
|
||||
drain_pty(master_fd)
|
||||
|
||||
res = send_cmd(sock, "client:expand-badge-fixture", session_id=session_id)
|
||||
if not res.get("ok"):
|
||||
raise AssertionError(f"fixture failed: {res}")
|
||||
print(f"fixture={res['output']}")
|
||||
time.sleep(0.5)
|
||||
screen = send_cmd(sock, "client:history", session_id=session_id).get("output", "")
|
||||
if "Edited demo.txt" not in screen:
|
||||
raise AssertionError("fixture did not install edit message")
|
||||
|
||||
state = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"before={state}")
|
||||
assert state["diff_mode"] == "Inline", state
|
||||
assert state["input"] == "", state
|
||||
|
||||
# CSI-u for Alt+Shift+e: codepoint 101 ('e'), modifier value 4
|
||||
# (1 + shift bit 1 + alt bit 2). This is the enhanced keyboard
|
||||
# protocol path used by modern terminals and crossterm.
|
||||
os.write(master_fd, b"\x1b[101;4u")
|
||||
time.sleep(0.5)
|
||||
|
||||
after = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"after_csi_u={after}")
|
||||
if after["diff_mode"] != "FullInline" or after["input"]:
|
||||
# Also try the legacy Alt+e encoding so failures print both states.
|
||||
os.write(master_fd, b"\x1be")
|
||||
time.sleep(0.5)
|
||||
after_legacy = json.loads(send_cmd(sock, "client:expand-badge-state", session_id=session_id)["output"])
|
||||
print(f"after_legacy={after_legacy}")
|
||||
raise AssertionError(
|
||||
"Alt+Shift+E did not expand in headless PTY: "
|
||||
f"after_csi_u={after}, after_legacy={after_legacy}"
|
||||
)
|
||||
|
||||
print("PASS: headless PTY Alt+Shift+E expands edit badge without inserting text")
|
||||
return 0
|
||||
finally:
|
||||
if proc and proc.poll() is None:
|
||||
try:
|
||||
os.killpg(proc.pid, signal.SIGTERM)
|
||||
except Exception:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=3)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
if master_fd is not None:
|
||||
try:
|
||||
os.close(master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
if session_id:
|
||||
try:
|
||||
send_cmd(sock, f"destroy_session:{session_id}", timeout=5)
|
||||
except Exception:
|
||||
pass
|
||||
sock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,18 @@
|
||||
//! End-to-end tests for jcode using a mock provider
|
||||
//!
|
||||
//! These tests verify the full flow from user input to response
|
||||
//! without making actual API calls.
|
||||
|
||||
mod mock_provider;
|
||||
mod test_support;
|
||||
|
||||
mod ambient;
|
||||
mod binary_integration;
|
||||
mod burst_spawn;
|
||||
mod provider_behavior;
|
||||
mod reload_multiclient;
|
||||
mod safety;
|
||||
mod session_flow;
|
||||
mod transport;
|
||||
#[cfg(windows)]
|
||||
mod windows_lifecycle;
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Mock provider for e2e tests
|
||||
//!
|
||||
//! Returns pre-scripted StreamEvent sequences for deterministic testing.
|
||||
|
||||
use anyhow::Result;
|
||||
use async_stream::stream;
|
||||
use jcode::message::{Message, StreamEvent, ToolDefinition};
|
||||
use jcode::provider::{EventStream, Provider};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
pub struct MockProvider {
|
||||
responses: Arc<Mutex<VecDeque<Vec<StreamEvent>>>>,
|
||||
models: Vec<&'static str>,
|
||||
current_model: Arc<Mutex<String>>,
|
||||
/// Captured system prompts from complete() calls (for testing)
|
||||
pub captured_system_prompts: Arc<Mutex<Vec<String>>>,
|
||||
/// Captured resume session IDs from complete() calls (for testing)
|
||||
pub captured_resume_session_ids: Arc<Mutex<Vec<Option<String>>>>,
|
||||
/// Captured model names from complete() calls (for testing)
|
||||
pub captured_models: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl MockProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
responses: Arc::new(Mutex::new(VecDeque::new())),
|
||||
models: Vec::new(),
|
||||
current_model: Arc::new(Mutex::new("mock".to_string())),
|
||||
captured_system_prompts: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_resume_session_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_models: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_models(models: Vec<&'static str>) -> Self {
|
||||
let current = models
|
||||
.first()
|
||||
.map(|m| (*m).to_string())
|
||||
.unwrap_or_else(|| "mock".to_string());
|
||||
Self {
|
||||
responses: Arc::new(Mutex::new(VecDeque::new())),
|
||||
models,
|
||||
current_model: Arc::new(Mutex::new(current)),
|
||||
captured_system_prompts: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_resume_session_ids: Arc::new(Mutex::new(Vec::new())),
|
||||
captured_models: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a response (sequence of StreamEvents) to be returned on next complete() call
|
||||
pub fn queue_response(&self, events: Vec<StreamEvent>) {
|
||||
self.responses.lock().unwrap().push_back(events);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Provider for MockProvider {
|
||||
async fn complete(
|
||||
&self,
|
||||
_messages: &[Message],
|
||||
_tools: &[ToolDefinition],
|
||||
system: &str,
|
||||
resume_session_id: Option<&str>,
|
||||
) -> Result<EventStream> {
|
||||
// Capture the system prompt for testing
|
||||
self.captured_system_prompts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(system.to_string());
|
||||
self.captured_resume_session_ids
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(resume_session_id.map(|s| s.to_string()));
|
||||
self.captured_models.lock().unwrap().push(self.model());
|
||||
|
||||
let events = self
|
||||
.responses
|
||||
.lock()
|
||||
.unwrap()
|
||||
.pop_front()
|
||||
.unwrap_or_default();
|
||||
|
||||
let stream = stream! {
|
||||
for event in events {
|
||||
yield Ok(event);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn model(&self) -> String {
|
||||
self.current_model.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
fn set_model(&self, model: &str) -> Result<()> {
|
||||
if !self.models.is_empty() && !self.models.contains(&model) {
|
||||
anyhow::bail!("Unknown model: {}", model);
|
||||
}
|
||||
*self.current_model.lock().unwrap() = model.to_string();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn available_models(&self) -> Vec<&'static str> {
|
||||
self.models.clone()
|
||||
}
|
||||
|
||||
fn fork(&self) -> Arc<dyn Provider> {
|
||||
let current = self.current_model.lock().unwrap().clone();
|
||||
Arc::new(MockProvider {
|
||||
responses: self.responses.clone(),
|
||||
models: self.models.clone(),
|
||||
current_model: Arc::new(Mutex::new(current)),
|
||||
captured_system_prompts: self.captured_system_prompts.clone(),
|
||||
captured_resume_session_ids: self.captured_resume_session_ids.clone(),
|
||||
captured_models: self.captured_models.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
/// Test that multi-turn conversation works with session resume
|
||||
#[tokio::test]
|
||||
async fn test_multi_turn_conversation() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// First turn response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("I'll remember that.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-abc".to_string()),
|
||||
]);
|
||||
|
||||
// Second turn response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("You said hello earlier.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-abc".to_string()),
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
// First turn
|
||||
let response1 = agent.run_once_capture("Hello").await?;
|
||||
assert_eq!(response1, "I'll remember that.");
|
||||
|
||||
// Second turn - should use session resume
|
||||
let response2 = agent.run_once_capture("What did I say?").await?;
|
||||
assert_eq!(response2, "You said hello earlier.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that token usage is tracked
|
||||
#[tokio::test]
|
||||
async fn test_token_usage() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TokenUsage {
|
||||
input_tokens: Some(10),
|
||||
output_tokens: Some(20),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
},
|
||||
StreamEvent::TextDelta("Response".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-123".to_string()),
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Test").await?;
|
||||
assert_eq!(response, "Response");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test error handling
|
||||
#[tokio::test]
|
||||
async fn test_stream_error() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Starting...".to_string()),
|
||||
StreamEvent::Error {
|
||||
message: "Something went wrong".to_string(),
|
||||
retry_after_secs: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let result = agent.run_once_capture("Test").await;
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Something went wrong")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test model cycling over the socket interface (server + client)
|
||||
#[tokio::test]
|
||||
async fn test_socket_model_cycle_supported_models() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::with_models(vec!["gpt-5.2-codex", "claude-opus-4-5-20251101"]);
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let request_id = client.cycle_model(1).await?;
|
||||
|
||||
let mut saw_model_changed = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::ModelChanged {
|
||||
id, model, error, ..
|
||||
} if id == request_id => {
|
||||
assert!(error.is_none(), "Expected successful model change");
|
||||
assert_eq!(model, "claude-opus-4-5-20251101");
|
||||
saw_model_changed = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
assert!(saw_model_changed, "Did not receive model_changed event");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that resume restores model selection and tool output in history
|
||||
#[tokio::test]
|
||||
async fn test_resume_restores_model_and_tool_history() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-resume-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
let mut session = Session::create(None, Some("Resume Test".to_string()));
|
||||
session.model = Some("gpt-5.2-codex".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "Run a tool".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
jcode::message::Role::Assistant,
|
||||
vec![
|
||||
jcode::message::ContentBlock::Text {
|
||||
text: "Running...".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
jcode::message::ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "bash".to_string(),
|
||||
input: serde_json::json!({"cmd": "echo hi"}),
|
||||
thought_signature: None,
|
||||
},
|
||||
],
|
||||
);
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "hi\n".to_string(),
|
||||
is_error: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
// Default model = claude, resume should switch to gpt-5.2-codex
|
||||
let provider = MockProvider::with_models(vec!["claude-opus-4-5-20251101", "gpt-5.2-codex"]);
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
|
||||
let mut history_event = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::History {
|
||||
id,
|
||||
messages,
|
||||
provider_model,
|
||||
..
|
||||
} if id == resume_id => {
|
||||
history_event = Some((messages, provider_model));
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
let (messages, provider_model) =
|
||||
history_event.ok_or_else(|| anyhow::anyhow!("Did not receive history event"))?;
|
||||
|
||||
assert_eq!(provider_model, Some("gpt-5.2-codex".to_string()));
|
||||
|
||||
let tool_msg = messages
|
||||
.iter()
|
||||
.find(|m| m.role == "tool")
|
||||
.ok_or_else(|| anyhow::anyhow!("Tool message missing in history"))?;
|
||||
assert!(tool_msg.content.contains("hi"));
|
||||
let tool_data = tool_msg
|
||||
.tool_data
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Tool metadata missing in history"))?;
|
||||
assert_eq!(tool_data.name, "bash");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that subscribe selfdev hint marks the session as canary
|
||||
#[tokio::test]
|
||||
async fn test_resume_session_with_local_history_uses_metadata_only_history() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-target-subscribe-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
let mut session = Session::create(None, Some("Target Subscribe Test".to_string()));
|
||||
session.model = Some("model-a".to_string());
|
||||
session.provider_session_id = Some("provider-resume-123".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "Existing local history".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
jcode::message::Role::Assistant,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "Existing assistant response".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a"]));
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("resumed ok".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
|
||||
let mut saw_done = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Done { id } if id == subscribe_id => {
|
||||
saw_done = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(saw_done, "Did not receive subscribe done event");
|
||||
|
||||
let resume_id = client
|
||||
.resume_session_with_options(&session.id, true, false)
|
||||
.await?;
|
||||
let mut history_checked = false;
|
||||
let mut resume_done = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline && !(history_checked && resume_done) {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
messages,
|
||||
provider_model,
|
||||
..
|
||||
} if id == resume_id => {
|
||||
assert_eq!(session_id, session.id);
|
||||
assert_eq!(
|
||||
messages.len(),
|
||||
2,
|
||||
"resume history should include persisted local messages"
|
||||
);
|
||||
assert_eq!(messages[0].role, "user");
|
||||
assert!(messages[0].content.contains("Existing local history"));
|
||||
assert_eq!(messages[1].role, "assistant");
|
||||
assert!(
|
||||
messages[1].content.contains("Existing assistant response"),
|
||||
"resume history should include persisted assistant response"
|
||||
);
|
||||
assert_eq!(provider_model, Some("model-a".to_string()));
|
||||
history_checked = true;
|
||||
}
|
||||
ServerEvent::Done { id } if id == resume_id => {
|
||||
resume_done = true;
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == resume_id => {
|
||||
anyhow::bail!("resume_session failed: {}", message);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(history_checked, "Did not receive resume history event");
|
||||
assert!(resume_done, "Did not receive resume done event");
|
||||
|
||||
let msg_id = client.send_message("continue resumed session").await?;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut saw_message_done = false;
|
||||
let mut seen_events = Vec::new();
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
seen_events.push(format!("{event:?}"));
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg_id) {
|
||||
saw_message_done = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
saw_message_done,
|
||||
"Did not receive Done for resumed message. Seen events: {}\nstate={}\nhistory={}\nlogs={}",
|
||||
seen_events.join(" | "),
|
||||
debug_run_command(debug_socket_path.clone(), "state", Some(&session.id))
|
||||
.await
|
||||
.unwrap_or_else(|err| format!("<state error: {err}>")),
|
||||
debug_run_command(debug_socket_path.clone(), "history", Some(&session.id))
|
||||
.await
|
||||
.unwrap_or_else(|err| format!("<history error: {err}>")),
|
||||
std::env::var_os("JCODE_HOME")
|
||||
.and_then(|home| latest_log_excerpt(std::path::Path::new(&home)))
|
||||
.unwrap_or_else(|| "<no logs>".to_string())
|
||||
);
|
||||
|
||||
let resume_ids = provider.captured_resume_session_ids.lock().unwrap().clone();
|
||||
assert_eq!(
|
||||
resume_ids.last().cloned(),
|
||||
Some(Some("provider-resume-123".to_string()))
|
||||
);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// End-to-end: resume_all_sessions continues a live session whose last
|
||||
/// visible turn is an unanswered (interrupted) user message, and reports a
|
||||
/// summary describing the resumed session.
|
||||
#[tokio::test]
|
||||
async fn test_resume_all_sessions_continues_interrupted_live_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-resume-all-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
// A session left with a pending user turn the assistant never answered.
|
||||
let mut session = Session::create(None, Some("Interrupted Session".to_string()));
|
||||
session.model = Some("model-a".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::Text {
|
||||
text: "keep going on the migration".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::with_models(vec!["model-a"]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Continuing the migration now.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
let _ = collect_until_done_unix(&mut client, subscribe_id).await?;
|
||||
|
||||
// Make the interrupted session live by attaching this client to it.
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
let _ = collect_until_done_unix(&mut client, resume_id).await?;
|
||||
|
||||
// Ask the server to continue every interrupted live session.
|
||||
let resume_all_id = client.resume_all_sessions().await?;
|
||||
|
||||
let mut saw_continuation = false;
|
||||
let mut resume_all_result: Option<(usize, usize)> = None;
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline && (!saw_continuation || resume_all_result.is_none()) {
|
||||
let event = tokio::time::timeout(Duration::from_secs(2), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::TextDelta { text } if text.contains("Continuing the migration now.") => {
|
||||
saw_continuation = true;
|
||||
}
|
||||
ServerEvent::ResumeAllResult {
|
||||
id,
|
||||
resumed,
|
||||
skipped,
|
||||
..
|
||||
} if id == resume_all_id => {
|
||||
resume_all_result = Some((resumed, skipped));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
assert!(
|
||||
saw_continuation,
|
||||
"interrupted live session should stream a continuation after resume_all_sessions"
|
||||
);
|
||||
let (resumed, skipped) =
|
||||
resume_all_result.ok_or_else(|| anyhow::anyhow!("did not receive ResumeAllResult"))?;
|
||||
assert_eq!(resumed, 1, "exactly one interrupted session should resume");
|
||||
assert_eq!(skipped, 0, "no live session should be skipped");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resume_session_reports_reload_interruption_for_peer_sessions() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-reload-interruption-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
|
||||
let mut session = Session::create(None, Some("Reload Interrupted Session".to_string()));
|
||||
session.model = Some("model-a".to_string());
|
||||
session.add_message(
|
||||
jcode::message::Role::User,
|
||||
vec![jcode::message::ContentBlock::ToolResult {
|
||||
tool_use_id: "tool_bash_1".to_string(),
|
||||
content: "[Tool 'bash' interrupted by server reload after 0.2s]".to_string(),
|
||||
is_error: Some(true),
|
||||
}],
|
||||
);
|
||||
session.save()?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a"]));
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
let subscribe_events = collect_until_done_unix(&mut client, subscribe_id).await?;
|
||||
assert!(
|
||||
subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Done { id } if *id == subscribe_id)),
|
||||
"expected subscribe to finish, got: {subscribe_events:?}"
|
||||
);
|
||||
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
let events = collect_until_done_unix(&mut client, resume_id).await?;
|
||||
|
||||
let history_event = events
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
ServerEvent::History {
|
||||
id,
|
||||
session_id,
|
||||
was_interrupted,
|
||||
..
|
||||
} if *id == resume_id => Some((session_id.clone(), *was_interrupted)),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("Did not receive resume history event: {events:?}"))?;
|
||||
|
||||
assert_eq!(history_event.0, session.id);
|
||||
assert_eq!(
|
||||
history_event.1,
|
||||
Some(true),
|
||||
"reload-interrupted peer sessions should be marked interrupted so clients auto-continue"
|
||||
);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that subscribe selfdev hint marks the session as canary
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_selfdev_hint_marks_canary() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(None, Some(true), None, false, false)
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == subscribe_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let history_event = client.get_history_event().await?;
|
||||
match history_event {
|
||||
ServerEvent::History { is_canary, .. } => {
|
||||
assert_eq!(is_canary, Some(true));
|
||||
}
|
||||
_ => anyhow::bail!("Expected history event after subscribe"),
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that working_dir alone no longer upgrades a session to self-dev.
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_working_dir_without_selfdev_hint_stays_normal() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let fake_repo = tempfile::tempdir()?;
|
||||
std::fs::create_dir_all(fake_repo.path().join(".git"))?;
|
||||
std::fs::write(
|
||||
fake_repo.path().join("Cargo.toml"),
|
||||
"[package]\nname = \"jcode\"\nversion = \"0.0.0\"\n",
|
||||
)?;
|
||||
let nested_dir = fake_repo.path().join("nested").join("worktree");
|
||||
std::fs::create_dir_all(&nested_dir)?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
let subscribe_id = client
|
||||
.subscribe_with_info(
|
||||
Some(nested_dir.display().to_string()),
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == subscribe_id) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let history_event = client.get_history_event().await?;
|
||||
match history_event {
|
||||
ServerEvent::History { is_canary, .. } => {
|
||||
assert_eq!(is_canary, Some(false));
|
||||
}
|
||||
_ => anyhow::bail!("Expected history event after subscribe"),
|
||||
}
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that switching models resets the provider resume session
|
||||
#[tokio::test]
|
||||
async fn test_model_switch_resets_provider_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a", "model-b"]));
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("hello".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-1".to_string()),
|
||||
]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("again".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
|
||||
let msg_id = client.send_message("hello").await?;
|
||||
let mut saw_done1 = false;
|
||||
// First-turn Done includes full agent-loop startup; slow Windows CI
|
||||
// runners intermittently exceeded the old 5s budget (CI flake). Keep the
|
||||
// loop tolerant of individual read timeouts and bound only the total wait.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let Ok(event) = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(event?, ServerEvent::Done { id } if id == msg_id) {
|
||||
saw_done1 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_done1, "Did not receive Done for first message");
|
||||
|
||||
let model_id = client.cycle_model(1).await?;
|
||||
let mut saw_model = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let Ok(event) = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(event?, ServerEvent::ModelChanged { id, error: None, .. } if id == model_id) {
|
||||
saw_model = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_model, "Did not receive ModelChanged after cycle");
|
||||
|
||||
let msg2_id = client.send_message("second").await?;
|
||||
let mut saw_done2 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while Instant::now() < deadline {
|
||||
let Ok(event) = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matches!(event?, ServerEvent::Done { id } if id == msg2_id) {
|
||||
saw_done2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_done2, "Did not receive Done for second message");
|
||||
|
||||
let resume_ids = provider.captured_resume_session_ids.lock().unwrap().clone();
|
||||
assert_eq!(resume_ids.len(), 2);
|
||||
assert_eq!(resume_ids[0], None);
|
||||
assert_eq!(resume_ids[1], None);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that switching models only affects the active session
|
||||
#[tokio::test]
|
||||
async fn test_model_switch_is_per_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = Arc::new(MockProvider::with_models(vec!["model-a", "model-b"]));
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("one".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-1".to_string()),
|
||||
]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("two".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("session-2".to_string()),
|
||||
]);
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("three".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
]);
|
||||
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider.clone();
|
||||
let server_instance = server::Server::new_with_paths(
|
||||
provider_dyn,
|
||||
socket_path.clone(),
|
||||
debug_socket_path.clone(),
|
||||
);
|
||||
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let mut client1 = wait_for_server_client(&socket_path).await?;
|
||||
let mut client2 = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
|
||||
// Give server time to set up both client sessions
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
let msg1 = client1.send_message("hello").await?;
|
||||
let mut done1 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client1.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg1) {
|
||||
done1 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(done1, "Did not receive Done for client1 message");
|
||||
|
||||
let msg2 = client2.send_message("hello").await?;
|
||||
let mut done2 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client2.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg2) {
|
||||
done2 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(done2, "Did not receive Done for client2 message");
|
||||
|
||||
let model_id = client1.cycle_model(1).await?;
|
||||
let mut saw_model = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client1.read_event()).await??;
|
||||
if matches!(event, ServerEvent::ModelChanged { id, error: None, .. } if id == model_id) {
|
||||
saw_model = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(saw_model, "Did not receive ModelChanged after cycle");
|
||||
|
||||
let msg3 = client2.send_message("after").await?;
|
||||
let mut done3 = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client2.read_event()).await??;
|
||||
if matches!(event, ServerEvent::Done { id } if id == msg3) {
|
||||
done3 = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(done3, "Did not receive Done for client2 after switch");
|
||||
|
||||
let models = provider.captured_models.lock().unwrap().clone();
|
||||
assert!(models.len() >= 3, "Expected at least 3 model captures");
|
||||
assert_eq!(models[2], "model-a");
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that the system prompt does NOT identify the agent as "Claude Code"
|
||||
/// The agent should identify as "jcode" or just a generic "coding assistant powered by Claude"
|
||||
#[tokio::test]
|
||||
async fn test_system_prompt_no_claude_code_identity() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = Arc::new(MockProvider::new());
|
||||
|
||||
// Queue a simple response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("I'm a coding assistant.".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("test-identity-123".to_string()),
|
||||
]);
|
||||
|
||||
// Keep a clone of Arc<MockProvider> before converting to Arc<dyn Provider>
|
||||
let provider_for_check = provider.clone();
|
||||
let provider_dyn: Arc<dyn jcode::provider::Provider> = provider;
|
||||
let registry = Registry::new(provider_dyn.clone()).await;
|
||||
let mut agent = Agent::new(provider_dyn, registry);
|
||||
|
||||
// Run a simple query - we just need to trigger a complete() call
|
||||
let _ = agent.run_once_capture("Who are you?").await?;
|
||||
|
||||
// Get the captured system prompt from our Arc<MockProvider>
|
||||
let captured_prompts = provider_for_check.captured_system_prompts.lock().unwrap();
|
||||
|
||||
assert!(
|
||||
!captured_prompts.is_empty(),
|
||||
"No system prompts were captured"
|
||||
);
|
||||
|
||||
let system_prompt = &captured_prompts[0];
|
||||
|
||||
// Check only the identity portion at the start of the system prompt.
|
||||
// User-provided instruction files may legitimately mention Claude Code CLI.
|
||||
// The first ~500 chars contain the identity statement
|
||||
let identity_portion = if system_prompt.len() > 500 {
|
||||
&system_prompt[..500]
|
||||
} else {
|
||||
system_prompt
|
||||
};
|
||||
let lower_identity = identity_portion.to_lowercase();
|
||||
|
||||
// The identity portion should NOT say "you are claude code" or similar
|
||||
assert!(
|
||||
!lower_identity.contains("you are claude code"),
|
||||
"System prompt should NOT identify as 'You are Claude Code'. Found: {}",
|
||||
identity_portion
|
||||
);
|
||||
|
||||
// Should identify as jcode
|
||||
assert!(
|
||||
lower_identity.contains("jcode"),
|
||||
"System prompt should identify as jcode. Found: {}",
|
||||
identity_portion
|
||||
);
|
||||
|
||||
// It's OK if it says "powered by Claude" or just "Claude" (the model name)
|
||||
// It's OK if project context references "Claude Code CLI" as a tool
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
//! In-process multi-client reload tests.
|
||||
//!
|
||||
//! These exercise the real server reload fan-out and streaming-interrupt paths
|
||||
//! against a `MockProvider` with multiple clients attached, without execing a
|
||||
//! new binary (the server runs with `JCODE_TEST_SESSION=1`, so
|
||||
//! `await_reload_signal` performs all the in-process bookkeeping -- recovery
|
||||
//! intents, graceful shutdown, marker writes, and client fan-out -- but skips
|
||||
//! the final `exec`). This is the cheapest way to cover the multi-client +
|
||||
//! streaming behavior that the binary-level reload tests cannot reach without
|
||||
//! real credentials and a release build.
|
||||
|
||||
use crate::test_support::*;
|
||||
|
||||
/// Spin up an in-process server backed by a `MockProvider`.
|
||||
async fn start_inprocess_server(
|
||||
label: &str,
|
||||
provider: Arc<dyn jcode::provider::Provider>,
|
||||
) -> Result<(
|
||||
std::path::PathBuf,
|
||||
std::path::PathBuf,
|
||||
tokio::task::JoinHandle<Result<()>>,
|
||||
)> {
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-reload-mc-{label}-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
wait_for_socket(&socket_path).await?;
|
||||
Ok((socket_path, debug_socket_path, server_handle))
|
||||
}
|
||||
|
||||
/// Read events from `client` until a `Reloading` event is seen or the deadline
|
||||
/// elapses. Returns whether a `Reloading` event was observed plus the events
|
||||
/// seen. A clean disconnect terminates the scan (returning `false`).
|
||||
async fn read_until_reloading(
|
||||
client: &mut server::Client,
|
||||
deadline: Instant,
|
||||
) -> Result<(bool, Vec<ServerEvent>)> {
|
||||
let mut seen = Vec::new();
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client.read_event()).await {
|
||||
Ok(Ok(event)) => {
|
||||
let is_reloading = matches!(event, ServerEvent::Reloading { .. });
|
||||
seen.push(event);
|
||||
if is_reloading {
|
||||
return Ok((true, seen));
|
||||
}
|
||||
}
|
||||
Ok(Err(_)) => return Ok((false, seen)),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Ok((false, seen))
|
||||
}
|
||||
|
||||
async fn subscribe_new_session(socket_path: &std::path::Path) -> Result<(server::Client, String)> {
|
||||
let mut client = server::Client::connect_with_path(socket_path.to_path_buf()).await?;
|
||||
let sub = client.subscribe().await?;
|
||||
let _ = collect_until_done_unix(&mut client, sub).await?;
|
||||
let history = client.get_history_event().await?;
|
||||
let session_id = match &history {
|
||||
ServerEvent::History { session_id, .. } => session_id.clone(),
|
||||
other => anyhow::bail!("expected history, got {other:?}"),
|
||||
};
|
||||
Ok((client, session_id))
|
||||
}
|
||||
|
||||
/// Clients attached to MULTIPLE independent sessions must each be notified when
|
||||
/// any client triggers a reload, because a reload restarts the whole server.
|
||||
/// This exercises `handle_reload`'s fan-out across every live session's
|
||||
/// `event_txs`, not just the triggering session.
|
||||
#[tokio::test]
|
||||
async fn reload_notifies_clients_across_independent_sessions() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("multisession", provider).await?;
|
||||
|
||||
let result = async {
|
||||
let (mut client_a, _sid_a) = subscribe_new_session(&socket_path).await?;
|
||||
let (mut client_b, _sid_b) = subscribe_new_session(&socket_path).await?;
|
||||
let (mut client_c, _sid_c) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
// Let all three senders register.
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// Only client A asks for the reload.
|
||||
client_a.reload().await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let (a_saw, a_events) = read_until_reloading(&mut client_a, deadline).await?;
|
||||
let (b_saw, b_events) = read_until_reloading(&mut client_b, deadline).await?;
|
||||
let (c_saw, c_events) = read_until_reloading(&mut client_c, deadline).await?;
|
||||
|
||||
let fmt = |events: &[ServerEvent]| {
|
||||
events.iter().map(|e| format!("{e:?}")).collect::<Vec<_>>()
|
||||
};
|
||||
assert!(a_saw, "initiator session must be notified; saw: {:?}", fmt(&a_events));
|
||||
assert!(
|
||||
b_saw,
|
||||
"a client on a SECOND independent session must be notified the server is reloading; saw: {:?}",
|
||||
fmt(&b_events)
|
||||
);
|
||||
assert!(
|
||||
c_saw,
|
||||
"a client on a THIRD independent session must be notified the server is reloading; saw: {:?}",
|
||||
fmt(&c_events)
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// When a client takes over a live session (the multi-client reconnect path),
|
||||
/// the SUCCESSOR connection must still receive the reload notification and the
|
||||
/// superseded connection must be cleanly disconnected -- not left hanging.
|
||||
#[tokio::test]
|
||||
async fn reload_notifies_successor_after_session_takeover() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("takeover", provider).await?;
|
||||
|
||||
let result = async {
|
||||
// Original owner.
|
||||
let (mut client_a, session_id) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
// Successor reconnects to the same session with takeover semantics.
|
||||
let mut client_b = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
let resume_b = client_b
|
||||
.resume_session_with_options(&session_id, true, true)
|
||||
.await?;
|
||||
let _ = collect_until_history_unix(&mut client_b, resume_b).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
// The successor triggers the reload.
|
||||
client_b.reload().await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let (b_saw, b_events) = read_until_reloading(&mut client_b, deadline).await?;
|
||||
assert!(
|
||||
b_saw,
|
||||
"the live successor connection must be told the server is reloading; saw: {:?}",
|
||||
b_events
|
||||
.iter()
|
||||
.map(|e| format!("{e:?}"))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// The superseded original connection must end (disconnect) rather than
|
||||
// hang indefinitely. It may have received an Error/Reloading first.
|
||||
let a_deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut a_terminated = false;
|
||||
while Instant::now() < a_deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client_a.read_event()).await {
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => {
|
||||
a_terminated = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
a_terminated,
|
||||
"the superseded original connection must be cleanly disconnected, not left hanging"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// A client with an in-flight STREAMING turn during a reload must reach a
|
||||
/// terminal state (Reloading and/or Done) without hanging. This drives the
|
||||
/// graceful-shutdown interrupt path inside `run_turn_streaming_mpsc` where the
|
||||
/// server fires each session's shutdown signal mid-stream.
|
||||
#[tokio::test]
|
||||
async fn reload_interrupts_in_flight_streaming_turn_without_hanging() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
// A long stream of text deltas with NO MessageEnd: the turn stays "running"
|
||||
// so the reload graceful-shutdown path must interrupt it. The mock yields
|
||||
// all events eagerly, but the server still processes the stream while the
|
||||
// reload signal races in; the key assertion is that the streaming client
|
||||
// reaches a terminal event quickly rather than hanging.
|
||||
let mut long_stream = Vec::new();
|
||||
for i in 0..2000 {
|
||||
long_stream.push(StreamEvent::TextDelta(format!("chunk-{i} ")));
|
||||
}
|
||||
long_stream.push(StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
});
|
||||
provider.queue_response(long_stream);
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("streaming", provider).await?;
|
||||
|
||||
let result = async {
|
||||
let (mut client, _session_id) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
// Kick off the streaming turn.
|
||||
let msg_id = client.send_message("please stream a lot").await?;
|
||||
|
||||
// Wait until the stream is actually flowing before reloading.
|
||||
let mut saw_text = false;
|
||||
let stream_deadline = Instant::now() + Duration::from_secs(3);
|
||||
while Instant::now() < stream_deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client.read_event()).await {
|
||||
Ok(Ok(ServerEvent::TextDelta { .. })) => {
|
||||
saw_text = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => break,
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(saw_text, "streaming turn should emit text before reload");
|
||||
|
||||
// Reload while the turn is in flight.
|
||||
client.reload().await?;
|
||||
|
||||
// The client must reach a terminal state quickly: either Reloading,
|
||||
// a Done for the message, or a disconnect. It must NOT hang.
|
||||
let mut terminal = false;
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
while Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_millis(500), client.read_event()).await {
|
||||
Ok(Ok(ServerEvent::Reloading { .. })) => {
|
||||
terminal = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(ServerEvent::Done { id })) if id == msg_id => {
|
||||
terminal = true;
|
||||
break;
|
||||
}
|
||||
Ok(Ok(_)) => continue,
|
||||
Ok(Err(_)) => {
|
||||
terminal = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
terminal,
|
||||
"a streaming turn interrupted by reload must reach a terminal state (Reloading/Done/disconnect) without hanging"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// The reload marker must be active around the time clients are told to
|
||||
/// reconnect, so that a client which disconnects mid-reload is classified as
|
||||
/// `Reloading` rather than `Crashed` by `disconnect_disposition`.
|
||||
#[tokio::test]
|
||||
async fn reload_marker_is_active_when_clients_are_notified() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let (socket_path, debug_socket_path, server_handle) =
|
||||
start_inprocess_server("marker", provider).await?;
|
||||
|
||||
let result = async {
|
||||
let (mut client, _session_id) = subscribe_new_session(&socket_path).await?;
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
client.reload().await?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let (saw_reloading, events) = read_until_reloading(&mut client, deadline).await?;
|
||||
assert!(
|
||||
saw_reloading,
|
||||
"client should observe Reloading; saw: {:?}",
|
||||
events.iter().map(|e| format!("{e:?}")).collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let mut marker_active = false;
|
||||
let marker_deadline = Instant::now() + Duration::from_secs(2);
|
||||
while Instant::now() < marker_deadline {
|
||||
if server::reload_marker_active(Duration::from_secs(30)) {
|
||||
marker_active = true;
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
assert!(
|
||||
marker_active,
|
||||
"the reload marker must be active around the time clients are told to reconnect"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
server::clear_reload_marker();
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// =============================================================================
|
||||
// Ambient Mode Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
/// Test safety system: action classification
|
||||
#[test]
|
||||
fn test_safety_classification() {
|
||||
use jcode::safety::SafetySystem;
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
// Tier 1: auto-allowed
|
||||
assert!(safety.classify("read") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("glob") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("grep") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("memory") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("todoread") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("todowrite") == jcode::safety::ActionTier::AutoAllowed);
|
||||
|
||||
// Tier 2: requires permission
|
||||
assert!(safety.classify("bash") == jcode::safety::ActionTier::RequiresPermission);
|
||||
assert!(safety.classify("edit") == jcode::safety::ActionTier::RequiresPermission);
|
||||
assert!(safety.classify("write") == jcode::safety::ActionTier::RequiresPermission);
|
||||
assert!(
|
||||
safety.classify("create_pull_request") == jcode::safety::ActionTier::RequiresPermission
|
||||
);
|
||||
assert!(safety.classify("send_email") == jcode::safety::ActionTier::RequiresPermission);
|
||||
|
||||
// Case insensitive
|
||||
assert!(safety.classify("READ") == jcode::safety::ActionTier::AutoAllowed);
|
||||
assert!(safety.classify("Bash") == jcode::safety::ActionTier::RequiresPermission);
|
||||
}
|
||||
|
||||
/// Test safety system: permission request queue + decision flow
|
||||
#[test]
|
||||
fn test_safety_permission_flow() {
|
||||
use jcode::safety::{PermissionRequest, PermissionResult, SafetySystem, Urgency};
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
// Count existing pending requests (may have leftover state from other tests)
|
||||
let baseline = safety.pending_requests().len();
|
||||
|
||||
// Queue a permission request
|
||||
let req = PermissionRequest {
|
||||
id: "test_perm_flow_001".to_string(),
|
||||
action: "create_pull_request".to_string(),
|
||||
description: "Create PR for auth fixes".to_string(),
|
||||
rationale: "Found 3 failing auth tests".to_string(),
|
||||
urgency: Urgency::High,
|
||||
wait: false,
|
||||
created_at: chrono::Utc::now(),
|
||||
context: None,
|
||||
};
|
||||
|
||||
let result = safety.request_permission(req);
|
||||
assert!(matches!(result, PermissionResult::Queued { .. }));
|
||||
|
||||
// Verify our request was added
|
||||
let pending = safety.pending_requests();
|
||||
assert_eq!(pending.len(), baseline + 1);
|
||||
assert!(
|
||||
pending
|
||||
.iter()
|
||||
.any(|p| p.action == "create_pull_request" && p.id == "test_perm_flow_001")
|
||||
);
|
||||
|
||||
// Record an approval decision
|
||||
let _ = safety.record_decision(
|
||||
"test_perm_flow_001",
|
||||
true,
|
||||
"test",
|
||||
Some("looks good".to_string()),
|
||||
);
|
||||
|
||||
// Verify our request was removed
|
||||
assert_eq!(safety.pending_requests().len(), baseline);
|
||||
}
|
||||
|
||||
/// Test safety system: transcript saving
|
||||
#[test]
|
||||
fn test_safety_transcript() {
|
||||
use jcode::safety::{AmbientTranscript, SafetySystem, TranscriptStatus};
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
let transcript = AmbientTranscript {
|
||||
session_id: "test_ambient_001".to_string(),
|
||||
started_at: chrono::Utc::now(),
|
||||
ended_at: Some(chrono::Utc::now()),
|
||||
status: TranscriptStatus::Complete,
|
||||
provider: "mock".to_string(),
|
||||
model: "mock-model".to_string(),
|
||||
actions: vec![],
|
||||
pending_permissions: 0,
|
||||
summary: Some("Test cycle completed".to_string()),
|
||||
compactions: 0,
|
||||
memories_modified: 3,
|
||||
conversation: None,
|
||||
};
|
||||
|
||||
// Should not panic
|
||||
let result = safety.save_transcript(&transcript);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
/// Test safety system: summary generation
|
||||
#[test]
|
||||
fn test_safety_summary_generation() {
|
||||
use jcode::safety::{ActionLog, ActionTier, SafetySystem};
|
||||
|
||||
let safety = SafetySystem::new();
|
||||
|
||||
// Log some actions
|
||||
safety.log_action(ActionLog {
|
||||
action_type: "memory_consolidation".to_string(),
|
||||
description: "Merged 2 duplicate memories".to_string(),
|
||||
tier: ActionTier::AutoAllowed,
|
||||
details: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
safety.log_action(ActionLog {
|
||||
action_type: "memory_prune".to_string(),
|
||||
description: "Pruned 1 stale memory".to_string(),
|
||||
tier: ActionTier::AutoAllowed,
|
||||
details: None,
|
||||
timestamp: chrono::Utc::now(),
|
||||
});
|
||||
|
||||
let summary = safety.generate_summary();
|
||||
assert!(summary.contains("Merged 2 duplicate memories"));
|
||||
assert!(summary.contains("Pruned 1 stale memory"));
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_session_restores_persisted_compaction_for_provider_context() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-compaction-resume-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = CapturingCompactionProvider::new();
|
||||
let captured_messages = provider.captured_messages();
|
||||
let provider: Arc<dyn Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
let result = async {
|
||||
let mut session = Session::create_with_id(
|
||||
"session_resume_compaction_restore_test".to_string(),
|
||||
None,
|
||||
Some("resume compaction restore test".to_string()),
|
||||
);
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "older user turn".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::Assistant,
|
||||
vec![ContentBlock::Text {
|
||||
text: "older assistant turn".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.add_message(
|
||||
Role::User,
|
||||
vec![ContentBlock::Text {
|
||||
text: "recent preserved turn".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
);
|
||||
session.compaction = Some(StoredCompactionState {
|
||||
summary_text: "Worked on Gemini OAuth reload fixes.".to_string(),
|
||||
openai_encrypted_content: None,
|
||||
covers_up_to_turn: 2,
|
||||
original_turn_count: 2,
|
||||
compacted_count: 2,
|
||||
});
|
||||
session.save()?;
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let mut client = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
|
||||
let subscribe_id = client.subscribe().await?;
|
||||
let _ = collect_until_done_unix(&mut client, subscribe_id).await?;
|
||||
|
||||
let resume_id = client.resume_session(&session.id).await?;
|
||||
let _ = collect_until_history_unix(&mut client, resume_id).await?;
|
||||
|
||||
let message_id = client
|
||||
.send_message("continue from the restored session")
|
||||
.await?;
|
||||
let mut seen_events = Vec::new();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
let event = timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
let is_done = matches!(event, ServerEvent::Done { id } if id == message_id);
|
||||
let is_error = matches!(event, ServerEvent::Error { id, .. } if id == message_id);
|
||||
seen_events.push(format!("{event:?}"));
|
||||
if is_done {
|
||||
break;
|
||||
}
|
||||
if is_error {
|
||||
anyhow::bail!(
|
||||
"message request failed while validating compaction restore: {}",
|
||||
seen_events.join(" | ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let captured = captured_messages.lock().unwrap();
|
||||
assert_eq!(
|
||||
captured.len(),
|
||||
1,
|
||||
"expected exactly one provider completion call"
|
||||
);
|
||||
let provider_messages = &captured[0];
|
||||
assert!(
|
||||
provider_messages.len() >= 3,
|
||||
"expected summary + preserved tail + new user message"
|
||||
);
|
||||
|
||||
let summary_text = flatten_text_blocks(&provider_messages[0]);
|
||||
assert!(summary_text.contains("Previous Conversation Summary"));
|
||||
assert!(summary_text.contains("Gemini OAuth reload fixes"));
|
||||
|
||||
let joined = provider_messages
|
||||
.iter()
|
||||
.map(flatten_text_blocks)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n---\n");
|
||||
assert!(joined.contains("recent preserved turn"));
|
||||
assert!(joined.contains("continue from the restored session"));
|
||||
assert!(!joined.contains("older user turn"));
|
||||
assert!(!joined.contains("older assistant turn"));
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
result
|
||||
}
|
||||
|
||||
/// Test that a simple text response works
|
||||
#[tokio::test]
|
||||
async fn test_simple_response() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
|
||||
// Queue a simple response
|
||||
provider.queue_response(vec![
|
||||
StreamEvent::TextDelta("Hello! ".to_string()),
|
||||
StreamEvent::TextDelta("How can I help?".to_string()),
|
||||
StreamEvent::MessageEnd {
|
||||
stop_reason: Some("end_turn".to_string()),
|
||||
},
|
||||
StreamEvent::SessionId("test-session-123".to_string()),
|
||||
]);
|
||||
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
|
||||
let response = agent.run_once_capture("Say hello").await?;
|
||||
let saved = Session::load(agent.session_id())?;
|
||||
|
||||
assert_eq!(response, "Hello! How can I help?");
|
||||
assert!(saved.is_debug, "test sessions should be marked debug");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_agent_clear_preserves_debug_flag() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let registry = Registry::new(provider.clone()).await;
|
||||
let mut agent = Agent::new(provider, registry);
|
||||
agent.set_debug(true);
|
||||
let old_session_id = agent.session_id().to_string();
|
||||
|
||||
agent.clear();
|
||||
|
||||
assert_ne!(agent.session_id(), old_session_id);
|
||||
assert!(agent.is_debug());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_debug_create_session_marks_debug() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-debug-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = debug_create_headless_session(debug_socket_path.clone()).await?;
|
||||
let session = Session::load(&session_id)?;
|
||||
assert!(session.is_debug);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_debug_create_selfdev_session_marks_canary() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-debug-selfdev-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let session_id = debug_create_headless_session_with_command(
|
||||
debug_socket_path.clone(),
|
||||
"create_session:selfdev:/tmp",
|
||||
)
|
||||
.await?;
|
||||
let session = Session::load(&session_id)?;
|
||||
assert!(session.is_debug);
|
||||
assert!(session.is_canary);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_preserves_debug_for_resumed_debug_session() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let runtime_dir = short_runtime_dir(format!(
|
||||
"jcode-clear-debug-test-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
let socket_path = runtime_dir.join("jcode.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-debug.sock");
|
||||
|
||||
let provider = MockProvider::new();
|
||||
let provider: Arc<dyn jcode::provider::Provider> = Arc::new(provider);
|
||||
let server_instance =
|
||||
server::Server::new_with_paths(provider, socket_path.clone(), debug_socket_path.clone());
|
||||
let server_handle = tokio::spawn(async move { server_instance.run().await });
|
||||
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
|
||||
let debug_session_id = debug_create_headless_session(debug_socket_path.clone()).await?;
|
||||
let mut client = server::Client::connect_with_path(socket_path.clone()).await?;
|
||||
let resume_id = client.resume_session(&debug_session_id).await?;
|
||||
|
||||
// Drain resume completion so clear() events are unambiguous.
|
||||
let mut saw_resume_history = false;
|
||||
let resume_deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < resume_deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::History { id, .. } if id == resume_id => {
|
||||
saw_resume_history = true;
|
||||
break;
|
||||
}
|
||||
ServerEvent::Error { id, message, .. } if id == resume_id => {
|
||||
anyhow::bail!("resume_session failed: {}", message);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !saw_resume_history {
|
||||
anyhow::bail!("Timed out waiting for resume history event");
|
||||
}
|
||||
|
||||
client.clear().await?;
|
||||
|
||||
let mut new_session_id = None;
|
||||
let clear_deadline = Instant::now() + Duration::from_secs(5);
|
||||
while Instant::now() < clear_deadline {
|
||||
let event = tokio::time::timeout(Duration::from_secs(1), client.read_event()).await??;
|
||||
match event {
|
||||
ServerEvent::Ack { .. } => continue,
|
||||
ServerEvent::SessionId { session_id } => {
|
||||
new_session_id = Some(session_id);
|
||||
}
|
||||
ServerEvent::Done { .. } if new_session_id.is_some() => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let new_session_id = new_session_id
|
||||
.ok_or_else(|| anyhow::anyhow!("Did not receive new session id after clear"))?;
|
||||
assert_ne!(new_session_id, debug_session_id);
|
||||
let session = Session::load(&new_session_id)?;
|
||||
assert!(session.is_debug);
|
||||
|
||||
abort_server_and_cleanup(&server_handle, &socket_path, &debug_socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_websocket_transport_matches_unix_socket_for_subscribe_history_message_and_resume()
|
||||
-> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let unix = run_unix_transport_scenario().await?;
|
||||
let websocket = run_websocket_transport_scenario().await?;
|
||||
|
||||
assert!(
|
||||
unix.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Ack { id } if *id == 1))
|
||||
);
|
||||
assert!(
|
||||
unix.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 1))
|
||||
);
|
||||
assert!(
|
||||
websocket
|
||||
.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Ack { id } if *id == 1))
|
||||
);
|
||||
assert!(
|
||||
websocket
|
||||
.subscribe_events
|
||||
.iter()
|
||||
.any(|event| matches!(event, ServerEvent::Done { id } if *id == 1))
|
||||
);
|
||||
|
||||
let unix_history = unix
|
||||
.history_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing unix history event"))?;
|
||||
let websocket_history = websocket
|
||||
.history_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing websocket history event"))?;
|
||||
assert_eq!(
|
||||
unix_history, websocket_history,
|
||||
"history payload should match across transports"
|
||||
);
|
||||
|
||||
let unix_resume = unix
|
||||
.resume_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing unix resume history event"))?;
|
||||
let websocket_resume = websocket
|
||||
.resume_events
|
||||
.iter()
|
||||
.find_map(summarize_history_invariant)
|
||||
.ok_or_else(|| anyhow::anyhow!("missing websocket resume history event"))?;
|
||||
assert_eq!(
|
||||
unix_resume, websocket_resume,
|
||||
"resume history payload should match across transports"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
use crate::test_support::*;
|
||||
|
||||
struct SpawnedWindowsServer {
|
||||
_temp_root: tempfile::TempDir,
|
||||
home_dir: std::path::PathBuf,
|
||||
runtime_dir: std::path::PathBuf,
|
||||
install_dir: std::path::PathBuf,
|
||||
socket_path: std::path::PathBuf,
|
||||
debug_socket_path: std::path::PathBuf,
|
||||
stdout_path: std::path::PathBuf,
|
||||
stderr_path: std::path::PathBuf,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl SpawnedWindowsServer {
|
||||
fn jcode_binary() -> std::path::PathBuf {
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let release_binary = manifest_dir
|
||||
.join("target")
|
||||
.join("x86_64-pc-windows-msvc")
|
||||
.join("release")
|
||||
.join("jcode.exe");
|
||||
if release_binary.exists() {
|
||||
return release_binary;
|
||||
}
|
||||
std::path::PathBuf::from(env!("CARGO_BIN_EXE_jcode"))
|
||||
}
|
||||
|
||||
fn spawn(prefix: &str) -> Result<Self> {
|
||||
let temp_root = tempfile::Builder::new().prefix(prefix).tempdir()?;
|
||||
let home_dir = temp_root.path().join("home");
|
||||
let runtime_dir = temp_root.path().join("runtime");
|
||||
let install_dir = temp_root.path().join("install");
|
||||
let stdout_path = temp_root.path().join("server-stdout.log");
|
||||
let stderr_path = temp_root.path().join("server-stderr.log");
|
||||
std::fs::create_dir_all(&home_dir)?;
|
||||
std::fs::create_dir_all(&runtime_dir)?;
|
||||
std::fs::create_dir_all(&install_dir)?;
|
||||
|
||||
let socket_path = runtime_dir.join("jcode-windows-lifecycle.sock");
|
||||
let debug_socket_path = runtime_dir.join("jcode-windows-lifecycle-debug.sock");
|
||||
|
||||
let stdout_file = std::fs::File::create(&stdout_path)?;
|
||||
let stderr_file = std::fs::File::create(&stderr_path)?;
|
||||
let mut command = Command::new(Self::jcode_binary());
|
||||
command
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&socket_path)
|
||||
.arg("--provider")
|
||||
.arg("openai-compatible")
|
||||
.arg("--model")
|
||||
.arg("windows-e2e-model")
|
||||
.arg("serve")
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &install_dir)
|
||||
.env("JCODE_NO_TELEMETRY", "1")
|
||||
.env("JCODE_OPENAI_COMPAT_API_BASE", "http://127.0.0.1:9/v1")
|
||||
.env("JCODE_OPENAI_COMPAT_DEFAULT_MODEL", "windows-e2e-model")
|
||||
.env("JCODE_OPENAI_COMPAT_LOCAL_ENABLED", "1")
|
||||
.env("JCODE_DEBUG_CONTROL", "1")
|
||||
.env("JCODE_TEMP_SERVER", "1")
|
||||
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
|
||||
.env("RUST_BACKTRACE", "1")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
let child = command.spawn()?;
|
||||
|
||||
Ok(Self {
|
||||
_temp_root: temp_root,
|
||||
home_dir,
|
||||
runtime_dir,
|
||||
install_dir,
|
||||
socket_path,
|
||||
debug_socket_path,
|
||||
stdout_path,
|
||||
stderr_path,
|
||||
child,
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_ready(&self) -> Result<()> {
|
||||
wait_for_server_ready(&self.socket_path, &self.debug_socket_path).await
|
||||
}
|
||||
|
||||
fn apply_env<'a>(&self, command: &'a mut Command) -> &'a mut Command {
|
||||
command
|
||||
.env_remove("JCODE_TEST_SESSION")
|
||||
.env("JCODE_HOME", &self.home_dir)
|
||||
.env("JCODE_RUNTIME_DIR", &self.runtime_dir)
|
||||
.env("JCODE_INSTALL_DIR", &self.install_dir)
|
||||
.env("JCODE_NO_TELEMETRY", "1")
|
||||
.env("JCODE_OPENAI_COMPAT_API_BASE", "http://127.0.0.1:9/v1")
|
||||
.env("JCODE_OPENAI_COMPAT_DEFAULT_MODEL", "windows-e2e-model")
|
||||
.env("JCODE_OPENAI_COMPAT_LOCAL_ENABLED", "1")
|
||||
.env("JCODE_DEBUG_CONTROL", "1")
|
||||
.env("JCODE_TEMP_SERVER", "1")
|
||||
.env("JCODE_SERVER_OWNER_PID", std::process::id().to_string())
|
||||
.env("RUST_BACKTRACE", "1")
|
||||
}
|
||||
|
||||
fn jcode_command(&self) -> Command {
|
||||
let mut command = Command::new(Self::jcode_binary());
|
||||
self.apply_env(&mut command);
|
||||
command
|
||||
}
|
||||
|
||||
fn spawn_same_socket_child(
|
||||
&self,
|
||||
label: &str,
|
||||
) -> Result<(Child, std::path::PathBuf, std::path::PathBuf)> {
|
||||
let stdout_path = self._temp_root.path().join(format!("{label}-stdout.log"));
|
||||
let stderr_path = self._temp_root.path().join(format!("{label}-stderr.log"));
|
||||
let stdout_file = std::fs::File::create(&stdout_path)?;
|
||||
let stderr_file = std::fs::File::create(&stderr_path)?;
|
||||
let mut command = Command::new(Self::jcode_binary());
|
||||
self.apply_env(&mut command)
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&self.socket_path)
|
||||
.arg("--provider")
|
||||
.arg("openai-compatible")
|
||||
.arg("--model")
|
||||
.arg("windows-e2e-model")
|
||||
.arg("serve")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file));
|
||||
let child = command.spawn()?;
|
||||
Ok((child, stdout_path, stderr_path))
|
||||
}
|
||||
|
||||
fn dump_extra_logs(
|
||||
&self,
|
||||
label: &str,
|
||||
stdout_path: &std::path::Path,
|
||||
stderr_path: &std::path::Path,
|
||||
) {
|
||||
eprintln!("=== {label}: extra process diagnostics ===");
|
||||
for (name, path) in [
|
||||
("server stdout", stdout_path),
|
||||
("server stderr", stderr_path),
|
||||
] {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) if !contents.trim().is_empty() => {
|
||||
eprintln!("--- {name} ({}) ---\n{contents}", path.display());
|
||||
}
|
||||
Ok(_) => eprintln!("--- {name} ({}) was empty ---", path.display()),
|
||||
Err(err) => eprintln!("--- could not read {name} at {}: {err} ---", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dump_logs(&self, label: &str) {
|
||||
eprintln!("=== {label}: windows lifecycle server diagnostics ===");
|
||||
for (name, path) in [
|
||||
("server stdout", &self.stdout_path),
|
||||
("server stderr", &self.stderr_path),
|
||||
] {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) if !contents.trim().is_empty() => {
|
||||
eprintln!("--- {name} ({}) ---\n{contents}", path.display());
|
||||
}
|
||||
Ok(_) => eprintln!("--- {name} ({}) was empty ---", path.display()),
|
||||
Err(err) => eprintln!("--- could not read {name} at {}: {err} ---", path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(artifact_root) = std::env::var("JCODE_E2E_ARTIFACT_DIR") {
|
||||
let safe_label: String = label
|
||||
.chars()
|
||||
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
|
||||
.collect();
|
||||
let artifact_dir = std::path::PathBuf::from(artifact_root).join(safe_label);
|
||||
let _ = std::fs::create_dir_all(&artifact_dir);
|
||||
let _ = std::fs::copy(&self.stdout_path, artifact_dir.join("server-stdout.log"));
|
||||
let _ = std::fs::copy(&self.stderr_path, artifact_dir.join("server-stderr.log"));
|
||||
let logs_dir = self.home_dir.join("logs");
|
||||
if let Ok(entries) = std::fs::read_dir(logs_dir) {
|
||||
let copied_logs_dir = artifact_dir.join("jcode-logs");
|
||||
let _ = std::fs::create_dir_all(&copied_logs_dir);
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() {
|
||||
let _ = std::fs::copy(path, copied_logs_dir.join(entry.file_name()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SpawnedWindowsServer {
|
||||
fn drop(&mut self) {
|
||||
kill_child(&mut self.child);
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_server_unreachable(socket_path: &std::path::Path) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let connect_result = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
server::Client::connect_with_path(socket_path.to_path_buf()),
|
||||
)
|
||||
.await;
|
||||
match connect_result {
|
||||
Err(_) if Instant::now() >= deadline => {
|
||||
anyhow::bail!(
|
||||
"timed out waiting for server at {} to become unreachable after process exit",
|
||||
socket_path.display()
|
||||
);
|
||||
}
|
||||
Err(_) => {}
|
||||
Ok(Err(_)) => return Ok(()),
|
||||
Ok(Ok(mut client)) => {
|
||||
if client.ping().await.unwrap_or(false) {
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"server at {} remained reachable after process exit",
|
||||
socket_path.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_binary_server_accepts_clients_and_debug_cli() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let server = SpawnedWindowsServer::spawn("jcode-windows-lifecycle-")?;
|
||||
|
||||
let result = async {
|
||||
server.wait_ready().await?;
|
||||
|
||||
let mut client_a = wait_for_server_client(&server.socket_path).await?;
|
||||
anyhow::ensure!(client_a.ping().await?, "first protocol client should ping");
|
||||
|
||||
let mut client_b = wait_for_server_client(&server.socket_path).await?;
|
||||
anyhow::ensure!(client_b.ping().await?, "second protocol client should ping");
|
||||
|
||||
let info = debug_run_command_json(server.debug_socket_path.clone(), "server:info", None).await?;
|
||||
anyhow::ensure!(
|
||||
info.get("id").and_then(|value| value.as_str()).is_some(),
|
||||
"server:info should include an id: {info}"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
info.get("debug_control_enabled")
|
||||
.and_then(|value| value.as_bool())
|
||||
== Some(true),
|
||||
"server should honor JCODE_DEBUG_CONTROL in Windows e2e"
|
||||
);
|
||||
|
||||
let output = server
|
||||
.jcode_command()
|
||||
.arg("--no-update")
|
||||
.arg("--socket")
|
||||
.arg(&server.socket_path)
|
||||
.arg("debug")
|
||||
.arg("server:info")
|
||||
.output()?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::ensure!(
|
||||
output.status.success(),
|
||||
"debug CLI should round-trip to spawned Windows server. stdout: {stdout}; stderr: {stderr}"
|
||||
);
|
||||
let cli_info: serde_json::Value = serde_json::from_str(stdout.trim())?;
|
||||
anyhow::ensure!(
|
||||
cli_info.get("id") == info.get("id"),
|
||||
"debug CLI should target the same server as the protocol client"
|
||||
);
|
||||
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
server.dump_logs("binary-server-accepts-clients-and-debug-cli");
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn windows_binary_server_rebinds_named_pipe_after_exit() -> Result<()> {
|
||||
let _env = setup_test_env()?;
|
||||
let mut first = SpawnedWindowsServer::spawn("jcode-windows-rebind-")?;
|
||||
|
||||
let result = async {
|
||||
first.wait_ready().await?;
|
||||
let socket_path = first.socket_path.clone();
|
||||
let debug_socket_path = first.debug_socket_path.clone();
|
||||
kill_child(&mut first.child);
|
||||
wait_for_server_unreachable(&socket_path).await?;
|
||||
|
||||
let (mut second_child, second_stdout, second_stderr) =
|
||||
first.spawn_same_socket_child("second-server")?;
|
||||
let second_result = async {
|
||||
wait_for_server_ready(&socket_path, &debug_socket_path).await?;
|
||||
let mut client = wait_for_server_client(&socket_path).await?;
|
||||
anyhow::ensure!(client.ping().await?, "rebound server should answer ping");
|
||||
Ok::<_, anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
kill_child(&mut second_child);
|
||||
if second_result.is_err() {
|
||||
first.dump_extra_logs(
|
||||
"binary-server-rebind-second-server",
|
||||
&second_stdout,
|
||||
&second_stderr,
|
||||
);
|
||||
}
|
||||
second_result
|
||||
}
|
||||
.await;
|
||||
|
||||
if result.is_err() {
|
||||
first.dump_logs("binary-server-rebind-first-server");
|
||||
}
|
||||
result
|
||||
}
|
||||
Reference in New Issue
Block a user