Files
wehub-resource-sync a789495a98
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
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

948 lines
34 KiB
Rust

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(())
}