first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -1,15 +1,90 @@
|
||||
use futures::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
|
||||
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
|
||||
|
||||
use crate::proto::{
|
||||
client_message, run_command_response, server_message, ClientMessage, ErrorCode,
|
||||
InitializeResponse, RunCommandResponse, RunCommandSuccess, ServerMessage,
|
||||
};
|
||||
use crate::protocol;
|
||||
use galaxy_core::SessionId;
|
||||
use galaxyui::r#async::executor;
|
||||
use galaxyui_core::r#async::executor;
|
||||
|
||||
use super::*;
|
||||
use crate::proto::{
|
||||
client_message, host_scoped_request, notification, run_command_response, server_message,
|
||||
session_scoped_request, ClientMessage, CodebaseIndexStatus, CodebaseIndexStatusState,
|
||||
CodebaseIndexStatusUpdated, CodebaseIndexStatusesSnapshot, ErrorCode, GetDiffStateResponse,
|
||||
InitializeResponse, OpenBufferResponse, RemoteAgentContextSnapshot, RemoteContextFileProto,
|
||||
RunCommandResponse, RunCommandSuccess, ServerMessage, WriteFile,
|
||||
};
|
||||
use crate::protocol;
|
||||
|
||||
/// Extract the session-scoped inner message from a ClientMessage wrapper.
|
||||
fn unwrap_session_scoped(msg: &ClientMessage) -> &session_scoped_request::Message {
|
||||
match &msg.message {
|
||||
Some(client_message::Message::SessionScoped(w)) => w.message.as_ref().unwrap(),
|
||||
other => panic!("Expected SessionScoped, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_agent_context_snapshot_push_becomes_client_event() {
|
||||
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
||||
let (server_read, server_write) = tokio::io::split(server_stream);
|
||||
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||
drop(server_read);
|
||||
|
||||
let executor = executor::Background::default();
|
||||
let (_client, event_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
let mut writer = server_write.compat_write();
|
||||
|
||||
protocol::write_server_message(
|
||||
&mut writer,
|
||||
&ServerMessage {
|
||||
request_id: String::new(),
|
||||
message: Some(server_message::Message::RemoteAgentContextSnapshot(
|
||||
RemoteAgentContextSnapshot {
|
||||
revision: 7,
|
||||
home_dir: "/home/user".to_string(),
|
||||
skills: vec![crate::proto::RemoteSkillProto {
|
||||
path: "/home/user/.agents/skills/test/SKILL.md".to_string(),
|
||||
content: "skill content".to_string(),
|
||||
source: Some(crate::proto::remote_skill_proto::Source::Home(
|
||||
crate::proto::HomeSkillMetadata {},
|
||||
)),
|
||||
}],
|
||||
global_rules: vec![RemoteContextFileProto {
|
||||
path: "/home/user/.agents/AGENTS.md".to_string(),
|
||||
content: "rule content".to_string(),
|
||||
}],
|
||||
},
|
||||
)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
match event_rx.recv().await.unwrap() {
|
||||
ClientEvent::RemoteAgentContextSnapshotReceived { snapshot } => {
|
||||
assert_eq!(snapshot.revision, 7);
|
||||
assert_eq!(snapshot.skills[0].content, "skill content");
|
||||
assert_eq!(snapshot.global_rules[0].content, "rule content");
|
||||
}
|
||||
other => panic!("Expected RemoteAgentContextSnapshotReceived, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the host-scoped inner message from a ClientMessage wrapper.
|
||||
fn unwrap_host_scoped(msg: &ClientMessage) -> &host_scoped_request::Message {
|
||||
match &msg.message {
|
||||
Some(client_message::Message::HostScoped(w)) => w.message.as_ref().unwrap(),
|
||||
other => panic!("Expected HostScoped, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the notification inner message from a ClientMessage wrapper.
|
||||
fn unwrap_notification(msg: &ClientMessage) -> ¬ification::Message {
|
||||
match &msg.message {
|
||||
Some(client_message::Message::Notification(w)) => w.message.as_ref().unwrap(),
|
||||
other => panic!("Expected Notification, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic mock server: loops reading ClientMessages and responds using the
|
||||
/// provided closure. Exits cleanly on EOF.
|
||||
@@ -37,6 +112,73 @@ async fn mock_server_with<F>(
|
||||
}
|
||||
}
|
||||
|
||||
fn not_enabled_codebase_status(repo_path: &str) -> CodebaseIndexStatus {
|
||||
CodebaseIndexStatus {
|
||||
repo_path: repo_path.to_string(),
|
||||
state: CodebaseIndexStatusState::NotEnabled.into(),
|
||||
last_updated_epoch_millis: Some(123),
|
||||
progress_completed: None,
|
||||
progress_total: None,
|
||||
failure_message: None,
|
||||
root_hash: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codebase_index_push_messages_become_client_events() {
|
||||
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
||||
let (server_read, server_write) = tokio::io::split(server_stream);
|
||||
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||
drop(server_read);
|
||||
|
||||
let executor = executor::Background::default();
|
||||
let (_client, event_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
let mut writer = server_write.compat_write();
|
||||
|
||||
protocol::write_server_message(
|
||||
&mut writer,
|
||||
&ServerMessage {
|
||||
request_id: String::new(),
|
||||
message: Some(server_message::Message::CodebaseIndexStatusesSnapshot(
|
||||
CodebaseIndexStatusesSnapshot {
|
||||
statuses: vec![not_enabled_codebase_status("/repo")],
|
||||
},
|
||||
)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
protocol::write_server_message(
|
||||
&mut writer,
|
||||
&ServerMessage {
|
||||
request_id: String::new(),
|
||||
message: Some(server_message::Message::CodebaseIndexStatusUpdated(
|
||||
CodebaseIndexStatusUpdated {
|
||||
status: Some(not_enabled_codebase_status("/repo")),
|
||||
},
|
||||
)),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
writer.flush().await.unwrap();
|
||||
|
||||
match event_rx.recv().await.unwrap() {
|
||||
ClientEvent::CodebaseIndexStatusesSnapshotReceived { statuses } => {
|
||||
assert_eq!(statuses.len(), 1);
|
||||
assert_eq!(statuses[0].repo_path, "/repo");
|
||||
}
|
||||
other => panic!("Expected CodebaseIndexStatusesSnapshotReceived, got {other:?}"),
|
||||
}
|
||||
match event_rx.recv().await.unwrap() {
|
||||
ClientEvent::CodebaseIndexStatusUpdated { status } => {
|
||||
assert_eq!(status.repo_path, "/repo");
|
||||
}
|
||||
other => panic!("Expected CodebaseIndexStatusUpdated, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets up a duplex stream, spawns `mock_server_with` with the given responder,
|
||||
/// and returns a connected `RemoteServerClient`, its event receiver, and the
|
||||
/// background executor (which must be kept alive for the test duration).
|
||||
@@ -61,7 +203,7 @@ where
|
||||
));
|
||||
|
||||
let executor = executor::Background::default();
|
||||
let (client, event_rx) =
|
||||
let (client, event_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
(client, event_rx, executor)
|
||||
}
|
||||
@@ -75,7 +217,18 @@ async fn initialize_round_trip() {
|
||||
})
|
||||
});
|
||||
|
||||
let resp = client.initialize(None).await.unwrap();
|
||||
let resp = client
|
||||
.initialize(
|
||||
None,
|
||||
InitializeParams {
|
||||
user_id: String::new(),
|
||||
user_email: String::new(),
|
||||
crash_reporting_enabled: true,
|
||||
codebase_index_limits: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.server_version, "test-0.1.0");
|
||||
assert_eq!(resp.host_id, "test-host-id");
|
||||
}
|
||||
@@ -83,37 +236,55 @@ async fn initialize_round_trip() {
|
||||
#[tokio::test]
|
||||
async fn initialize_sends_empty_auth_token_when_none() {
|
||||
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
|
||||
match &msg.message {
|
||||
Some(client_message::Message::Initialize(init)) => {
|
||||
assert!(init.auth_token.is_empty());
|
||||
}
|
||||
other => panic!("Expected Initialize, got {other:?}"),
|
||||
}
|
||||
let session_scoped_request::Message::Initialize(init) = unwrap_session_scoped(msg) else {
|
||||
panic!("Expected Initialize");
|
||||
};
|
||||
assert!(init.auth_token.is_empty());
|
||||
server_message::Message::InitializeResponse(InitializeResponse {
|
||||
server_version: "test-0.1.0".to_string(),
|
||||
host_id: "test-host-id".to_string(),
|
||||
})
|
||||
});
|
||||
|
||||
client.initialize(None).await.unwrap();
|
||||
client
|
||||
.initialize(
|
||||
None,
|
||||
InitializeParams {
|
||||
user_id: String::new(),
|
||||
user_email: String::new(),
|
||||
crash_reporting_enabled: true,
|
||||
codebase_index_limits: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_sends_auth_token_when_provided() {
|
||||
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
|
||||
match &msg.message {
|
||||
Some(client_message::Message::Initialize(init)) => {
|
||||
assert_eq!(init.auth_token, "secret-token");
|
||||
}
|
||||
other => panic!("Expected Initialize, got {other:?}"),
|
||||
}
|
||||
let session_scoped_request::Message::Initialize(init) = unwrap_session_scoped(msg) else {
|
||||
panic!("Expected Initialize");
|
||||
};
|
||||
assert_eq!(init.auth_token, "secret-token");
|
||||
server_message::Message::InitializeResponse(InitializeResponse {
|
||||
server_version: "test-0.1.0".to_string(),
|
||||
host_id: "test-host-id".to_string(),
|
||||
})
|
||||
});
|
||||
|
||||
client.initialize(Some("secret-token")).await.unwrap();
|
||||
client
|
||||
.initialize(
|
||||
Some("secret-token"),
|
||||
InitializeParams {
|
||||
user_id: String::new(),
|
||||
user_email: String::new(),
|
||||
crash_reporting_enabled: true,
|
||||
codebase_index_limits: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -122,7 +293,7 @@ async fn authenticate_sends_fire_and_forget_message() {
|
||||
let (server_read, _server_write) = tokio::io::split(server_stream);
|
||||
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||
let executor = executor::Background::default();
|
||||
let (client, _event_rx) =
|
||||
let (client, _event_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
|
||||
client.authenticate("rotated-secret");
|
||||
@@ -130,12 +301,42 @@ async fn authenticate_sends_fire_and_forget_message() {
|
||||
let msg = protocol::read_client_message(&mut server_read.compat())
|
||||
.await
|
||||
.unwrap();
|
||||
match msg.message {
|
||||
Some(client_message::Message::Authenticate(auth)) => {
|
||||
assert_eq!(auth.auth_token, "rotated-secret");
|
||||
}
|
||||
other => panic!("Expected Authenticate, got {other:?}"),
|
||||
}
|
||||
let notification::Message::Authenticate(auth) = unwrap_notification(&msg) else {
|
||||
panic!("Expected Authenticate");
|
||||
};
|
||||
assert_eq!(auth.auth_token, "rotated-secret");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_host_scoped_returns_ok_when_connected() {
|
||||
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
||||
let (server_read, _server_write) = tokio::io::split(server_stream);
|
||||
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||
let executor = executor::Background::default();
|
||||
let (client, _event_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
|
||||
let msg = ClientMessage::host_scoped(
|
||||
"req-host-1".to_string(),
|
||||
host_scoped_request::Message::WriteFile(WriteFile {
|
||||
path: "/tmp/foo.txt".to_string(),
|
||||
content: "hello".to_string(),
|
||||
}),
|
||||
);
|
||||
|
||||
// On a healthy connection, dispatch succeeds (the message is queued).
|
||||
assert!(client.send_host_scoped(msg).is_ok());
|
||||
|
||||
// The queued message is written to the server with the host-scoped envelope.
|
||||
let received = protocol::read_client_message(&mut server_read.compat())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(received.request_id, "req-host-1");
|
||||
let host_scoped_request::Message::WriteFile(write) = unwrap_host_scoped(&received) else {
|
||||
panic!("Expected WriteFile host-scoped request");
|
||||
};
|
||||
assert_eq!(write.path, "/tmp/foo.txt");
|
||||
assert_eq!(write.content, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -146,25 +347,53 @@ async fn disconnected_on_closed_stream() {
|
||||
|
||||
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||
let executor = executor::Background::default();
|
||||
let (client, disconnect_rx) =
|
||||
let (client, disconnect_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
|
||||
// An initialize call on a dead stream must complete with an error rather than hang.
|
||||
let result = client.initialize(None).await;
|
||||
let result = client
|
||||
.initialize(
|
||||
None,
|
||||
InitializeParams {
|
||||
user_id: String::new(),
|
||||
user_email: String::new(),
|
||||
crash_reporting_enabled: true,
|
||||
codebase_index_limits: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// The reader task should detect EOF and emit a Disconnected event.
|
||||
let event = disconnect_rx.recv().await.unwrap();
|
||||
assert!(matches!(event, ClientEvent::Disconnected));
|
||||
|
||||
// After the Disconnected event has been observed, the reader task has
|
||||
// already stored `true` into the atomic flag (it does the store before
|
||||
// sending the event), so callers can rely on `is_disconnected()` to
|
||||
// short-circuit further requests.
|
||||
assert!(client.is_disconnected());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_disconnected_starts_false() {
|
||||
let (client, _disconnect_rx, _executor) = setup_mock_client(|_| {
|
||||
server_message::Message::InitializeResponse(InitializeResponse {
|
||||
server_version: "test-0.1.0".to_string(),
|
||||
host_id: "test-host-id".to_string(),
|
||||
})
|
||||
});
|
||||
|
||||
assert!(!client.is_disconnected());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_command_round_trip() {
|
||||
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
|
||||
let command = match &msg.message {
|
||||
Some(client_message::Message::RunCommand(req)) => req.command.clone(),
|
||||
other => panic!("Expected RunCommand, got {other:?}"),
|
||||
let session_scoped_request::Message::RunCommand(req) = unwrap_session_scoped(msg) else {
|
||||
panic!("Expected RunCommand");
|
||||
};
|
||||
let command = req.command.clone();
|
||||
server_message::Message::RunCommandResponse(RunCommandResponse {
|
||||
result: Some(run_command_response::Result::Success(RunCommandSuccess {
|
||||
stdout: format!("output of: {command}").into_bytes(),
|
||||
@@ -206,9 +435,17 @@ async fn concurrent_in_flight_requests() {
|
||||
for _ in 0..10 {
|
||||
let c = std::sync::Arc::clone(&client);
|
||||
handles.push(tokio::spawn(async move {
|
||||
c.initialize(None)
|
||||
.await
|
||||
.expect("concurrent initialize failed")
|
||||
c.initialize(
|
||||
None,
|
||||
InitializeParams {
|
||||
user_id: String::new(),
|
||||
user_email: String::new(),
|
||||
crash_reporting_enabled: true,
|
||||
codebase_index_limits: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("concurrent initialize failed")
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -308,3 +545,102 @@ async fn server_returns_error_for_malformed_message_with_parseable_id() {
|
||||
other => panic!("expected ErrorResponse, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A malformed *server* response carrying a parseable request_id that doesn't
|
||||
/// match a session-scoped pending request must surface as
|
||||
/// `HostScopedDecodeFailed` so the manager can fail the host request promptly
|
||||
/// instead of letting it hang until the request timeout.
|
||||
#[tokio::test]
|
||||
async fn malformed_host_scoped_response_emits_decode_failed_event() {
|
||||
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
||||
let (server_read, server_write) = tokio::io::split(server_stream);
|
||||
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||
drop(server_read);
|
||||
|
||||
let executor = executor::Background::default();
|
||||
let (_client, event_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
let mut server_write = server_write.compat_write();
|
||||
|
||||
// Field 1 (string): tag=0x0a, length=15, "host-req-decode", then invalid
|
||||
// trailing bytes (field 1, reserved wire type 7) so prost decode fails
|
||||
// while `try_extract_request_id` still recovers the request_id.
|
||||
let mut payload = Vec::new();
|
||||
payload.push(0x0a);
|
||||
payload.push(15);
|
||||
payload.extend_from_slice(b"host-req-decode");
|
||||
payload.extend_from_slice(&[0x0F, 0x01]);
|
||||
|
||||
let len = payload.len() as u32;
|
||||
server_write.write_all(&len.to_le_bytes()).await.unwrap();
|
||||
server_write.write_all(&payload).await.unwrap();
|
||||
server_write.flush().await.unwrap();
|
||||
|
||||
match event_rx.recv().await.unwrap() {
|
||||
ClientEvent::HostScopedDecodeFailed { request_id } => {
|
||||
assert_eq!(request_id.to_string(), "host-req-decode");
|
||||
}
|
||||
other => panic!("Expected HostScopedDecodeFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_diff_state_round_trips_as_session_scoped() {
|
||||
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
|
||||
match unwrap_session_scoped(msg) {
|
||||
session_scoped_request::Message::GetDiffState(req) => {
|
||||
assert_eq!(req.repo_path, "/repo");
|
||||
}
|
||||
other => panic!("Expected GetDiffState, got {other:?}"),
|
||||
}
|
||||
server_message::Message::GetDiffStateResponse(GetDiffStateResponse { result: None })
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.get_diff_state("/repo".to_string(), crate::proto::DiffMode::default())
|
||||
.await
|
||||
.expect("get_diff_state should succeed");
|
||||
assert!(resp.result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn open_buffer_round_trips_as_session_scoped() {
|
||||
let (client, _disconnect_rx, _executor) = setup_mock_client(|msg| {
|
||||
match unwrap_session_scoped(msg) {
|
||||
session_scoped_request::Message::OpenBuffer(req) => {
|
||||
assert_eq!(req.path, "/tmp/f.txt");
|
||||
assert!(!req.force_reload);
|
||||
}
|
||||
other => panic!("Expected OpenBuffer, got {other:?}"),
|
||||
}
|
||||
server_message::Message::OpenBufferResponse(OpenBufferResponse { result: None })
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.open_buffer("/tmp/f.txt".to_string(), false)
|
||||
.await
|
||||
.expect("open_buffer should succeed");
|
||||
assert!(resp.result.is_none());
|
||||
}
|
||||
|
||||
/// A session-scoped request on a connection that has already dropped resolves
|
||||
/// promptly with a transport error (no hang), because `pending_requests` is
|
||||
/// cleared on disconnect.
|
||||
#[tokio::test]
|
||||
async fn get_diff_state_on_dead_connection_errors_promptly() {
|
||||
let (client_stream, server_stream) = tokio::io::duplex(4096);
|
||||
drop(server_stream);
|
||||
|
||||
let (client_read, client_write) = tokio::io::split(client_stream);
|
||||
let executor = executor::Background::default();
|
||||
let (client, disconnect_rx, _failure_rx, _host_rx) =
|
||||
RemoteServerClient::new(client_read.compat(), client_write.compat_write(), &executor);
|
||||
|
||||
// Drain the Disconnected event so the reader-task teardown is observed.
|
||||
let _ = disconnect_rx.recv().await;
|
||||
|
||||
let result = client
|
||||
.get_diff_state("/repo".to_string(), crate::proto::DiffMode::default())
|
||||
.await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user