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
5 changes: 3 additions & 2 deletions apps/web/content/docs/contributors/architecture-overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ When a user runs `devo`:
model, model binding id, provider wire API, reasoning effort selection, permission
preset, and working directory.

`devo onboard` follows the same path, but forces the TUI into provider
onboarding mode.
`devo onboard` follows the same setup path, forces provider onboarding mode,
and exits after the provider/model binding is saved. The CLI then prompts the
user to run `devo` so the next process starts from the freshly written config.

`devo resume <session_id>` follows the same path with an initial session id.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ flowchart TD
4. `run_agent` 调用 `devo_tui::run_interactive_tui`。
5. TUI 以 `InitialTuiSession` 启动,其中包含当前 session id、model、model binding id、provider wire API、reasoning effort selection、permission preset 和 working directory。

`devo onboard` 使用同一路径,但强制 TUI 进入 provider onboarding mode。
`devo onboard` 使用同一条 setup 路径,但会强制进入 provider onboarding
mode,并在 provider/model binding 保存后退出。CLI 随后提示用户运行 `devo`,
让下一个进程从新写入的配置启动。

`devo resume <session_id>` 使用同一路径,并传入 initial session id。

Expand Down
8 changes: 5 additions & 3 deletions crates/cli/src/agent_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ use devo_util_paths::find_devo_home;
/// Runs the interactive coding-agent entrypoint.
///
/// `force_onboarding` forces the TUI to start in provider onboarding mode even
/// when a provider config already exists. `log_level` is forwarded to the
/// background server process, and `model_override` replaces the resolved model
/// for this session without mutating the stored provider config.
/// when a provider config already exists. `exit_after_onboarding` exits after a
/// successful onboarding save instead of continuing into the interactive TUI.
/// `log_level` is forwarded to the background server process.
pub(crate) async fn run_agent(
force_onboarding: bool,
exit_after_onboarding: bool,
log_level: Option<&str>,
initial_session_id: Option<SessionId>,
) -> Result<devo_tui::AppExit> {
Expand Down Expand Up @@ -107,6 +108,7 @@ pub(crate) async fn run_agent(
model_catalog,
saved_models,
show_model_onboarding: onboarding_mode,
exit_after_onboarding,
startup_warnings,
})
.await?;
Expand Down
105 changes: 102 additions & 3 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,28 @@ fn exit_messages(exit: &devo_tui::AppExit, color_enabled: bool) -> Vec<String> {
lines
}

fn onboarding_exit_messages(exit: &devo_tui::AppExit, color_enabled: bool) -> Vec<String> {
if !exit.onboarding_completed {
return Vec::new();
}
let complete = if color_enabled {
"\u{1b}[1;32mConfiguration complete\u{1b}[0m".to_string()
} else {
"Configuration complete".to_string()
};
let command = if color_enabled {
"\u{1b}[1;36mdevo\u{1b}[0m".to_string()
} else {
"devo".to_string()
};
vec![
complete,
String::new(),
"Next step:".to_string(),
format!(" {command}"),
]
}

async fn run_cli() -> Result<()> {
let cli = Cli::parse();
let log_level = cli.log_level.map(|level| level.to_string());
Expand All @@ -155,8 +177,14 @@ async fn run_cli() -> Result<()> {
// Resolve logging config early, install the process-wide file subscriber,
// and keep its non-blocking writer guard alive for the command lifetime.
let _logging = install_logging(&cli)?;
let exit = run_agent(/*force_onboarding*/ true, log_level.as_deref(), None).await?;
for line in exit_messages(&exit, /*color_enabled*/ true) {
let exit = run_agent(
/*force_onboarding*/ true,
/*exit_after_onboarding*/ true,
log_level.as_deref(),
None,
)
.await?;
for line in onboarding_exit_messages(&exit, /*color_enabled*/ true) {
println!("{line}");
}
Ok(())
Expand All @@ -176,6 +204,7 @@ async fn run_cli() -> Result<()> {
let _logging = install_logging(&cli)?;
let exit = run_agent(
/*force_onboarding*/ false,
/*exit_after_onboarding*/ false,
log_level.as_deref(),
Some(*session_id),
)
Expand All @@ -202,7 +231,13 @@ async fn run_cli() -> Result<()> {
maybe_print_startup_update(&cli).await;
let _logging = install_logging(&cli)?;
tracing::info!("default interactive command starting");
let exit = run_agent(/*force_onboarding*/ false, log_level.as_deref(), None).await?;
let exit = run_agent(
/*force_onboarding*/ false,
/*exit_after_onboarding*/ false,
log_level.as_deref(),
None,
)
.await?;
let exit_lines = exit_messages(&exit, /*color_enabled*/ true);
tracing::info!(
line_count = exit_lines.len(),
Expand Down Expand Up @@ -339,6 +374,7 @@ mod tests {
use super::cli_logging_overrides;
use super::exit_messages;
use super::format_token_usage_line;
use super::onboarding_exit_messages;

#[test]
fn cli_parses_supported_log_levels() {
Expand Down Expand Up @@ -527,6 +563,7 @@ mod tests {
let session_id = SessionId::new();
let exit = devo_tui::AppExit {
session_id: Some(session_id),
onboarding_completed: false,
turn_count: 1,
total_input_tokens: 10,
total_output_tokens: 2,
Expand All @@ -549,6 +586,7 @@ mod tests {
let session_id = SessionId::new();
let exit = devo_tui::AppExit {
session_id: Some(session_id),
onboarding_completed: false,
turn_count: 1,
total_input_tokens: 10,
total_output_tokens: 2,
Expand All @@ -561,4 +599,65 @@ mod tests {
let lines = exit_messages(&exit, /*color_enabled*/ true);
assert!(lines[1].contains("\u{1b}["));
}

#[test]
fn onboarding_exit_messages_include_next_step_after_success() {
let session_id = SessionId::new();
let exit = devo_tui::AppExit {
session_id: Some(session_id),
onboarding_completed: true,
turn_count: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
};

let lines = onboarding_exit_messages(&exit, /*color_enabled*/ false);

assert_eq!(
lines,
vec![
"Configuration complete".to_string(),
String::new(),
"Next step:".to_string(),
" devo".to_string(),
]
);
assert_eq!(lines.iter().any(|line| line.contains("devo resume")), false);
}

#[test]
fn onboarding_exit_messages_are_empty_without_success() {
let session_id = SessionId::new();
let exit = devo_tui::AppExit {
session_id: Some(session_id),
onboarding_completed: false,
turn_count: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
};

assert_eq!(
onboarding_exit_messages(&exit, /*color_enabled*/ false),
Vec::<String>::new()
);
}

#[test]
fn colorized_onboarding_exit_messages_include_ansi_sequences() {
let exit = devo_tui::AppExit {
session_id: None,
onboarding_completed: true,
turn_count: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
};

let lines = onboarding_exit_messages(&exit, /*color_enabled*/ true);

assert!(lines[0].contains("\u{1b}["));
assert!(lines[3].contains("\u{1b}["));
}
}
19 changes: 19 additions & 0 deletions crates/server/src/runtime/command_exec.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;

Expand All @@ -15,6 +16,7 @@ use crate::ProtocolErrorCode;
use crate::ServerEvent;
use crate::SuccessResponse;
use crate::runtime::ServerRuntime;
use crate::runtime::connection::SubscriptionFilter;
use devo_protocol::CommandExecExitedPayload;
use devo_protocol::CommandExecOutputDeltaPayload;
use devo_protocol::CommandExecOutputStream;
Expand Down Expand Up @@ -341,6 +343,23 @@ impl ServerRuntime {
Ok(cwd) => cwd,
Err((code, message)) => return self.error_response(request_id, code, message),
};
let command_exec_event_types = HashSet::from([
"command/exec/outputDelta".to_string(),
"command/exec/exited".to_string(),
]);
if let Some(connection) = self.connections.lock().await.get_mut(&connection_id) {
let already = connection.subscriptions.iter().any(|subscription| {
subscription.session_id == params.session_id
&& subscription.event_types == command_exec_event_types
});
if !already {
connection.subscriptions.push(SubscriptionFilter {
session_id: params.session_id,
event_types: command_exec_event_types,
include_child_agents: false,
});
}
}
match self
.command_exec_manager
.start(Arc::clone(self), connection_id, params, cwd)
Expand Down
54 changes: 43 additions & 11 deletions crates/server/tests/command_exec.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,28 +298,60 @@ async fn wait_for_command_exec_exit(
.await
.context("timed out waiting for command/exec notification")?
.context("notification channel closed before command/exec exited")?;
match notification["method"].as_str() {
Some("command/exec/outputDelta") => {
if notification["params"]["process_id"] != serde_json::json!(process_id) {
let payload = {
let method = notification["method"].as_str();
match method {
Some(method) if method.starts_with("_devo/") => {
let inner_method = method
.strip_prefix("_devo/")
.expect("starts_with checked prefix");
Some((inner_method, &notification["params"]))
}
Some("session/update") => {
let meta = &notification["params"]["_meta"];
let original_method = meta["devo/originalMethod"].as_str();
let original_event = &meta["devo/originalEvent"];
original_method.map(|method| {
let event_payload = if original_event.get("process_id").is_some() {
original_event
} else {
match method {
"command/exec/outputDelta" => {
&original_event["CommandExecOutputDelta"]
}
"command/exec/exited" => &original_event["CommandExecExited"],
_ => original_event,
}
};
(method, event_payload)
})
}
Some(method) => Some((method, &notification["params"])),
None => None,
}
};
match payload {
Some(("command/exec/outputDelta", params)) => {
if params["process_id"] != serde_json::json!(process_id) {
continue;
}
assert_notification_session(&notification["params"], session_id);
assert_eq!(notification["params"]["stream"], "pty");
let delta_base64 = notification["params"]["delta_base64"]
assert_notification_session(params, session_id);
assert_eq!(params["stream"], "pty");
let delta_base64 = params["delta_base64"]
.as_str()
.context("delta_base64 should be a string")?;
let bytes = BASE64_STANDARD.decode(delta_base64)?;
output.push_str(&String::from_utf8_lossy(&bytes));
}
Some("command/exec/exited") => {
if notification["params"]["process_id"] != serde_json::json!(process_id) {
Some(("command/exec/exited", params)) => {
if params["process_id"] != serde_json::json!(process_id) {
continue;
}
assert_notification_session(&notification["params"], session_id);
assert_eq!(notification["params"]["exit_code"], 0);
assert_notification_session(params, session_id);
assert_eq!(params["exit_code"], 0);
return Ok(output);
}
Some("session/started") if session_id.is_none() => {
Some(("session/started", _)) if session_id.is_none() => {
anyhow::bail!("sessionless command/exec unexpectedly created a session")
}
_ => {}
Expand Down
6 changes: 4 additions & 2 deletions crates/server/tests/end_to_end.rs
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,8 @@ async fn second_stdio_server_process_proxies_to_singleton() -> Result<()> {

#[tokio::test]
async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle() -> Result<()> {
let workspace = TempDir::new()?;
let test_cwd = workspace.path().to_string_lossy().into_owned();
let port = {
let listener = StdTcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
Expand Down Expand Up @@ -485,7 +487,7 @@ async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle()
"id": 2,
"method": "session/new",
"params": {
"cwd": "C:/repo",
"cwd": test_cwd,
"additionalDirectories": [],
"mcpServers": []
}
Expand Down Expand Up @@ -515,7 +517,7 @@ async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle()
.to_string();
assert_eq!(
session_response["result"]["_meta"]["devo/session"]["cwd"],
serde_json::json!("C:/repo")
serde_json::json!(test_cwd)
);

socket
Expand Down
4 changes: 4 additions & 0 deletions crates/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use devo_protocol::SessionId;
pub struct AppExit {
/// Active session identifier at exit, when one exists.
pub session_id: Option<SessionId>,
/// Whether provider onboarding completed successfully during this TUI run.
pub onboarding_completed: bool,
/// Total turns completed in the session.
pub turn_count: usize,
/// Total input tokens accumulated in the session.
Expand Down Expand Up @@ -58,6 +60,8 @@ pub struct InteractiveTuiConfig {
pub saved_models: Vec<SavedModelEntry>,
/// Whether to open the model picker on startup.
pub show_model_onboarding: bool,
/// Whether successful onboarding should exit the TUI immediately.
pub exit_after_onboarding: bool,
/// Non-fatal startup warnings to show in the transcript before user input.
pub startup_warnings: Vec<String>,
}
3 changes: 3 additions & 0 deletions crates/tui/src/app_event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ pub(crate) enum AppEvent {
/// Request to exit the TUI.
Exit(ExitMode),

/// Provider onboarding completed successfully.
OnboardingCompleted,

/// Submit the current composer text.
SubmitUserInput { text: String },

Expand Down
Loading
Loading