Skip to content
Open
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/cli/src/prompt_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ pub(crate) async fn run_prompt(
agent_coordinator: None,
client_filesystem: None,
client_terminal: None,
file_read_ledger: std::sync::Arc::new(devo_core::tools::FileReadLedger::new()),
local_web_search: None,
hooks: (!app_config.hooks.is_empty()).then(|| devo_core::HookRuntimeContext {
runner: devo_core::HookRunner::new(app_config.hooks.clone()),
Expand Down
9 changes: 8 additions & 1 deletion crates/client/src/client_core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub const ACP_PROMPT_COMPLETED_NOTIFICATION_METHOD: &str = "_devo/acp_prompt/com
const SERVER_RESPONSE_TIMEOUT: Duration = Duration::from_secs(10);

/// Synthetic notifications emitted when falling back to detached `session/prompt`.
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct ServerNotificationMessage {
pub method: String,
pub params: serde_json::Value,
Expand Down Expand Up @@ -412,6 +412,13 @@ impl ServerClientCore {
self.notifications_rx.recv().await
}

pub(crate) async fn recv_client_event(&mut self) -> Result<Option<crate::ClientEvent>> {
let Some(notification) = self.recv_notification().await else {
return Ok(None);
};
crate::events::client_event_from_notification(&notification)
}

pub(crate) async fn recv_event(&mut self) -> Result<Option<(String, ServerEvent)>> {
let Some(notification) = self.recv_notification().await else {
return Ok(None);
Expand Down
151 changes: 151 additions & 0 deletions crates/client/src/events.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
use anyhow::{Context, Result};
use devo_protocol::{
ACP_SESSION_UPDATE_METHOD, AcpSessionNotification, AcpSessionUpdate, DEVO_TURN_USAGE_META,
ServerEvent, SessionCompactionFailedPayload, SessionEventPayload, TurnEventPayload,
TurnUsageUpdatedPayload,
};

use crate::client_core::ServerNotificationMessage;

#[derive(Debug, Clone, PartialEq)]
pub enum ClientEvent {
TurnStarted(TurnEventPayload),
TurnCompleted(TurnEventPayload),
TurnFailed(TurnEventPayload),
TurnUsageUpdated(TurnUsageUpdatedPayload),
SessionCompactionStarted(SessionEventPayload),
SessionCompactionCompleted(SessionEventPayload),
SessionCompactionFailed(SessionCompactionFailedPayload),
Other(ServerNotificationMessage),
}

pub fn client_event_from_notification(
notification: &ServerNotificationMessage,
) -> Result<Option<ClientEvent>> {
if notification.method == ACP_SESSION_UPDATE_METHOD {
let acp_notification: AcpSessionNotification =
match serde_json::from_value(notification.params.clone()) {
Ok(notification) => notification,
Err(_) => return Ok(Some(ClientEvent::Other(notification.clone()))),
};
if let AcpSessionUpdate::UsageUpdate { meta, .. } = acp_notification.update
&& let Some(payload) = meta
.as_ref()
.and_then(|meta| meta.get(DEVO_TURN_USAGE_META))
{
return Ok(Some(ClientEvent::TurnUsageUpdated(
serde_json::from_value(payload.clone()).context("decode Devo usage metadata")?,
)));
}
return Ok(None);
}

let event: ServerEvent = match serde_json::from_value(notification.params.clone()) {
Ok(event) => event,
Err(_) => return Ok(Some(ClientEvent::Other(notification.clone()))),
};
Ok(Some(match event {
ServerEvent::TurnStarted(payload) => ClientEvent::TurnStarted(payload),
ServerEvent::TurnCompleted(payload) => ClientEvent::TurnCompleted(payload),
ServerEvent::TurnFailed(payload) => ClientEvent::TurnFailed(payload),
ServerEvent::TurnUsageUpdated(payload) => ClientEvent::TurnUsageUpdated(payload),
ServerEvent::SessionCompactionStarted(payload) => {
ClientEvent::SessionCompactionStarted(payload)
}
ServerEvent::SessionCompactionCompleted(payload) => {
ClientEvent::SessionCompactionCompleted(payload)
}
ServerEvent::SessionCompactionFailed(payload) => {
ClientEvent::SessionCompactionFailed(payload)
}
_ => ClientEvent::Other(notification.clone()),
}))
}

#[cfg(test)]
mod tests {
use devo_protocol::{SessionId, TurnId, TurnUsage, TurnUsageUpdatedPayload};

use super::ClientEvent;
use super::client_event_from_notification;
use crate::ServerNotificationMessage;

#[test]
fn client_event_usage_normalizes_devo_notification() {
let session_id = SessionId::new();
let turn_id = TurnId::new();
let payload = TurnUsageUpdatedPayload {
session_id,
turn_id,
usage: TurnUsage {
input_tokens: 700,
output_tokens: 30,
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
reasoning_output_tokens: None,
total_tokens: Some(730),
},
total_input_tokens: 1_300,
total_output_tokens: 80,
total_tokens: 1_380,
total_cache_read_tokens: 0,
last_query_input_tokens: 700,
context_window: Some(200_000),
};
let notification = ServerNotificationMessage {
method: "turn/usage/updated".to_string(),
params: serde_json::to_value(devo_protocol::ServerEvent::TurnUsageUpdated(
payload.clone(),
))
.expect("serialize usage event"),
};

assert_eq!(
client_event_from_notification(&notification).expect("decode client event"),
Some(ClientEvent::TurnUsageUpdated(payload))
);
}

#[test]
fn client_event_usage_normalizes_acp_metadata() {
let session_id = SessionId::new();
let turn_id = TurnId::new();
let payload = TurnUsageUpdatedPayload {
session_id,
turn_id,
usage: TurnUsage {
input_tokens: 700,
output_tokens: 30,
cache_creation_input_tokens: None,
cache_read_input_tokens: None,
reasoning_output_tokens: None,
total_tokens: Some(730),
},
total_input_tokens: 1_300,
total_output_tokens: 80,
total_tokens: 1_380,
total_cache_read_tokens: 0,
last_query_input_tokens: 700,
context_window: Some(200_000),
};
let notification = ServerNotificationMessage {
method: devo_protocol::ACP_SESSION_UPDATE_METHOD.to_string(),
params: serde_json::json!({
"sessionId": session_id,
"update": {
"sessionUpdate": "usage_update",
"used": 1_380,
"size": 200_000,
"_meta": {
"devo/turnUsage": payload,
},
},
}),
};

assert_eq!(
client_event_from_notification(&notification).expect("decode client event"),
Some(ClientEvent::TurnUsageUpdated(payload))
);
}
}
2 changes: 2 additions & 0 deletions crates/client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ mod acp_fs;
mod acp_permissions;
mod acp_terminal;
mod client_core;
mod events;
mod protocol_trace;
mod stdio;
mod websocket;

pub use client_core::ACP_PROMPT_COMPLETED_NOTIFICATION_METHOD;
pub use client_core::ACP_PROMPT_STARTED_NOTIFICATION_METHOD;
pub use client_core::ServerNotificationMessage;
pub use events::{ClientEvent, client_event_from_notification};
pub use stdio::*;
pub use websocket::*;
9 changes: 5 additions & 4 deletions crates/client/src/stdio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ pub use crate::client_core::ServerNotificationMessage;

const SERVER_CHILD_STDIN_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(100);
const SERVER_CHILD_EXIT_TIMEOUT: Duration = Duration::from_millis(500);
const STDIO_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(3);

#[derive(Debug, Clone)]
pub struct StdioServerClientConfig {
Expand Down Expand Up @@ -123,9 +122,7 @@ impl StdioServerClient {
tracing::info!("initializing stdio server client");
self.core
.set_client_capabilities(client_capabilities.clone());
let result = timeout(STDIO_INITIALIZE_TIMEOUT, self.core.initialize())
.await
.context("timed out waiting for initialize response from server")??;
let result = self.core.initialize().await?;
tracing::info!("stdio server client initialized");
Ok(result)
}
Expand Down Expand Up @@ -373,6 +370,10 @@ impl StdioServerClient {
self.core.recv_notification().await
}

pub async fn recv_client_event(&mut self) -> Result<Option<crate::ClientEvent>> {
self.core.recv_client_event().await
}

pub async fn recv_event(&mut self) -> Result<Option<(String, ServerEvent)>> {
self.core.recv_event().await
}
Expand Down
4 changes: 4 additions & 0 deletions crates/client/src/websocket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,10 @@ impl WebSocketServerClient {
self.core.recv_notification().await
}

pub async fn recv_client_event(&mut self) -> Result<Option<crate::ClientEvent>> {
self.core.recv_client_event().await
}

pub async fn recv_event(&mut self) -> Result<Option<(String, ServerEvent)>> {
self.core.recv_event().await
}
Expand Down
Loading
Loading