Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions crates/shelldeck-core/src/agent_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ pub struct AgentMessage {
#[serde(default)]
pub sequence: u64,
pub role: AgentMessageRole,
/// Original author survives later provider changes in the same session.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<AgentProvider>,
pub text: String,
pub at_ms: i64,
}
Expand Down Expand Up @@ -430,6 +433,7 @@ impl AgentSession {
id: Uuid::new_v4(),
sequence,
role,
provider: (role == AgentMessageRole::Agent).then_some(self.context.provider),
text: truncate_utf8(&text, MAX_AGENT_MESSAGE_BYTES),
at_ms: now_ms,
},
Expand Down Expand Up @@ -924,6 +928,52 @@ mod tests {
}
}

// SDTEST-1941 — earlier replies retain their author across provider changes
// and durable reload; older files without author metadata remain readable.
#[test]
fn provider_switch_preserves_reply_authorship_and_legacy_history() {
let mut session =
AgentSession::new("Providers", context(AgentProvider::Claude), 10).unwrap();
session.begin_run("first", 11).unwrap();
session.apply_stream_event(AgentStreamEvent::TextDelta("Claude".into()), 12, true);
session.apply_stream_event(AgentStreamEvent::TextDelta(" reply".into()), 13, true);
session
.finish(AgentSessionStatus::Completed, 14, true)
.unwrap();
session
.set_context(context(AgentProvider::Codex), 15)
.unwrap();
session.begin_run("second", 16).unwrap();
session.apply_stream_event(AgentStreamEvent::Text("Codex reply".into()), 17, true);
session
.finish(AgentSessionStatus::Completed, 18, true)
.unwrap();
let history = serde_json::to_value(&session).unwrap();
let restored: AgentSession = serde_json::from_value(history.clone()).unwrap();
let replies = restored
.messages
.iter()
.filter(|m| m.role == AgentMessageRole::Agent)
.map(|m| (m.provider, m.text.as_str()))
.collect::<Vec<_>>();
assert_eq!(
replies,
[
(Some(AgentProvider::Claude), "Claude reply"),
(Some(AgentProvider::Codex), "Codex reply")
]
);
let mut legacy = history;
for message in legacy["messages"].as_array_mut().unwrap() {
message.as_object_mut().unwrap().remove("provider");
}
let legacy: AgentSession = serde_json::from_value(legacy).unwrap();
assert_eq!(legacy.messages.len(), 4);
assert_eq!(legacy.messages[1].text, "Claude reply");
assert_eq!(legacy.messages[3].text, "Codex reply");
assert!(legacy.messages.iter().all(|m| m.provider.is_none()));
}

// SDTEST-1879 — SDUC-499
#[test]
fn sdtest_1879_collection_runs_bounded_sessions_concurrently_and_tracks_attention() {
Expand Down
61 changes: 49 additions & 12 deletions crates/shelldeck-ssh/src/tunnel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ impl TunnelManager {
pub async fn start_remote_forward(
&mut self,
handle: SharedHandle,
remote_host: String,
remote_port: u16,
local_host: String,
local_port: u16,
Expand All @@ -191,7 +192,7 @@ impl TunnelManager {
// Request remote forwarding from the server.
{
let h = handle.lock().await;
h.tcpip_forward("0.0.0.0", remote_port as u32)
h.tcpip_forward(&remote_host, remote_port as u32)
.await
.map_err(|e| SshError::Tunnel(format!("Remote forward request failed: {}", e)))?;
}
Expand Down Expand Up @@ -277,6 +278,20 @@ impl TunnelManager {

connections.abort_all();
while connections.join_next().await.is_some() {}
// Release the exact server-side listener even when the caller
// retains its SSH session after stopping this tunnel.
let cancel = async {
handle
.lock()
.await
.cancel_tcpip_forward(&remote_host, remote_port as u32)
.await
};
match tokio::time::timeout(std::time::Duration::from_secs(5), cancel).await {
Ok(Ok(())) => {}
Ok(Err(error)) => tracing::warn!(%error, "Remote forward cancellation failed"),
Err(_) => tracing::warn!("Remote forward cancellation timed out"),
}
*status_clone.lock() = TunnelStatus::Stopped;
});

Expand Down Expand Up @@ -810,7 +825,7 @@ mod tests {
/// hands its own [`server::Handle`] back to the test so the test can play
/// the remote side and open a forwarded-tcpip channel on demand.
struct RemoteForwardServer {
forward_requests: mpsc::UnboundedSender<(String, u32)>,
forward_requests: mpsc::UnboundedSender<(bool, String, u32)>,
server_handles: mpsc::UnboundedSender<server::Handle>,
}

Expand All @@ -827,16 +842,30 @@ mod tests {
port: &mut u32,
session: &mut Session,
) -> Result<bool, Self::Error> {
let _ = self.forward_requests.send((address.to_owned(), *port));
let _ = self
.forward_requests
.send((true, address.to_owned(), *port));
let _ = self.server_handles.send(session.handle());
Ok(true)
}

async fn cancel_tcpip_forward(
&mut self,
address: &str,
port: u32,
_session: &mut Session,
) -> Result<bool, Self::Error> {
let _ = self
.forward_requests
.send((false, address.to_owned(), port));
Ok(true)
}
}

#[allow(clippy::type_complexity)]
async fn start_remote_forward_server() -> (
SharedHandle,
mpsc::UnboundedReceiver<(String, u32)>,
mpsc::UnboundedReceiver<(bool, String, u32)>,
mpsc::UnboundedReceiver<server::Handle>,
mpsc::UnboundedReceiver<ForwardedTcpIpEvent>,
JoinHandle<()>,
Expand Down Expand Up @@ -1075,14 +1104,14 @@ mod tests {
let (local_port, echo_task) = start_local_echo_target().await;
let mut manager = TunnelManager::new();

// Unlike the local and SOCKS forwards, `start_remote_forward` does not
// keep the client handle: its channels are opened by the server, not by
// the tunnel. Production holds it through the owning `SshSession`, so
// the test has to hold it too or the transport closes underneath us.
// Keep the owning SSH session alive beyond tunnel stop, so receiving
// cancel-tcpip-forward proves explicit listener cleanup rather than
// incidental cleanup from disconnecting the transport.
let session_handle = handle.clone();
let id = manager
.start_remote_forward(
handle,
"127.0.0.1".to_owned(),
8443,
"127.0.0.1".to_owned(),
local_port,
Expand All @@ -1091,14 +1120,14 @@ mod tests {
.await
.expect("start remote forward");

// The server must be asked to listen on the *remote* port, on every
// interface — this is what makes it a reverse forward.
// Honor the configured remote interface instead of exposing the
// listener on every interface regardless of the saved profile.
assert_eq!(
timeout(Duration::from_secs(2), forward_requests.recv())
.await
.expect("tcpip-forward request timed out")
.expect("tcpip-forward request channel closed"),
("0.0.0.0".to_owned(), 8443),
(true, "127.0.0.1".to_owned(), 8443),
);

// Play the remote side: a client hit the forwarded port, so the server
Expand All @@ -1108,7 +1137,7 @@ mod tests {
.expect("server handle timed out")
.expect("server handle channel closed");
let channel = server_handle
.channel_open_forwarded_tcpip("0.0.0.0", 8443, "203.0.113.7", 54321)
.channel_open_forwarded_tcpip("127.0.0.1", 8443, "203.0.113.7", 54321)
.await
.expect("open forwarded-tcpip channel");

Expand Down Expand Up @@ -1136,6 +1165,14 @@ mod tests {

manager.get_tunnel(&id).expect("tunnel handle").stop();
wait_until_stopped(&manager, id).await;
assert_eq!(
timeout(Duration::from_secs(2), forward_requests.recv())
.await
.unwrap()
.unwrap(),
(false, "127.0.0.1".to_owned(), 8443),
"stop must cancel the exact listener while the session remains connected"
);

let mut one = [0_u8; 1];
assert_eq!(
Expand Down
10 changes: 7 additions & 3 deletions crates/shelldeck-ui/src/agent_console_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2328,8 +2328,8 @@ impl Render for AgentConsoleView {
.py(px(16.0));
if !has_content {
let subscription_setup = crate::cli_subscription_setup::render_subscription_setup(
context.provider.binary(),
matches!(context.target, AgentTarget::Ssh { .. }),
self.provider.binary(),
self.selected_connection.is_some(),
);
let mut empty_state = div()
.flex()
Expand Down Expand Up @@ -2380,7 +2380,11 @@ impl Render for AgentConsoleView {
};
let label = match message.role {
AgentMessageRole::User => t!("agents.role.you").to_string(),
AgentMessageRole::Agent => context.provider.display_name().to_string(),
AgentMessageRole::Agent => message
.provider
.unwrap_or(context.provider)
.display_name()
.to_string(),
AgentMessageRole::Error => t!("agents.activity.title").to_string(),
};
timeline_view = timeline_view.child(
Expand Down
Loading
Loading