Skip to content

Commit 16d271a

Browse files
authored
Merge pull request #109 from 7df-lab/dev/fix_bugs_0622
Fix explicit onboarding exit flow and onboarding viewport
2 parents d1aa004 + 27ae396 commit 16d271a

23 files changed

Lines changed: 556 additions & 84 deletions

apps/web/content/docs/contributors/architecture-overview.mdx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,9 @@ When a user runs `devo`:
7575
model, model binding id, provider wire API, reasoning effort selection, permission
7676
preset, and working directory.
7777

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

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

apps/web/content/docs/contributors/architecture-overview.zh.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ flowchart TD
6666
4. `run_agent` 调用 `devo_tui::run_interactive_tui`
6767
5. TUI 以 `InitialTuiSession` 启动,其中包含当前 session id、model、model binding id、provider wire API、reasoning effort selection、permission preset 和 working directory。
6868

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

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

crates/cli/src/agent_command.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ use devo_util_paths::find_devo_home;
2020
/// Runs the interactive coding-agent entrypoint.
2121
///
2222
/// `force_onboarding` forces the TUI to start in provider onboarding mode even
23-
/// when a provider config already exists. `log_level` is forwarded to the
24-
/// background server process, and `model_override` replaces the resolved model
25-
/// for this session without mutating the stored provider config.
23+
/// when a provider config already exists. `exit_after_onboarding` exits after a
24+
/// successful onboarding save instead of continuing into the interactive TUI.
25+
/// `log_level` is forwarded to the background server process.
2626
pub(crate) async fn run_agent(
2727
force_onboarding: bool,
28+
exit_after_onboarding: bool,
2829
log_level: Option<&str>,
2930
initial_session_id: Option<SessionId>,
3031
) -> Result<devo_tui::AppExit> {
@@ -107,6 +108,7 @@ pub(crate) async fn run_agent(
107108
model_catalog,
108109
saved_models,
109110
show_model_onboarding: onboarding_mode,
111+
exit_after_onboarding,
110112
startup_warnings,
111113
})
112114
.await?;

crates/cli/src/main.rs

Lines changed: 102 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,28 @@ fn exit_messages(exit: &devo_tui::AppExit, color_enabled: bool) -> Vec<String> {
146146
lines
147147
}
148148

149+
fn onboarding_exit_messages(exit: &devo_tui::AppExit, color_enabled: bool) -> Vec<String> {
150+
if !exit.onboarding_completed {
151+
return Vec::new();
152+
}
153+
let complete = if color_enabled {
154+
"\u{1b}[1;32mConfiguration complete\u{1b}[0m".to_string()
155+
} else {
156+
"Configuration complete".to_string()
157+
};
158+
let command = if color_enabled {
159+
"\u{1b}[1;36mdevo\u{1b}[0m".to_string()
160+
} else {
161+
"devo".to_string()
162+
};
163+
vec![
164+
complete,
165+
String::new(),
166+
"Next step:".to_string(),
167+
format!(" {command}"),
168+
]
169+
}
170+
149171
async fn run_cli() -> Result<()> {
150172
let cli = Cli::parse();
151173
let log_level = cli.log_level.map(|level| level.to_string());
@@ -155,8 +177,14 @@ async fn run_cli() -> Result<()> {
155177
// Resolve logging config early, install the process-wide file subscriber,
156178
// and keep its non-blocking writer guard alive for the command lifetime.
157179
let _logging = install_logging(&cli)?;
158-
let exit = run_agent(/*force_onboarding*/ true, log_level.as_deref(), None).await?;
159-
for line in exit_messages(&exit, /*color_enabled*/ true) {
180+
let exit = run_agent(
181+
/*force_onboarding*/ true,
182+
/*exit_after_onboarding*/ true,
183+
log_level.as_deref(),
184+
None,
185+
)
186+
.await?;
187+
for line in onboarding_exit_messages(&exit, /*color_enabled*/ true) {
160188
println!("{line}");
161189
}
162190
Ok(())
@@ -176,6 +204,7 @@ async fn run_cli() -> Result<()> {
176204
let _logging = install_logging(&cli)?;
177205
let exit = run_agent(
178206
/*force_onboarding*/ false,
207+
/*exit_after_onboarding*/ false,
179208
log_level.as_deref(),
180209
Some(*session_id),
181210
)
@@ -202,7 +231,13 @@ async fn run_cli() -> Result<()> {
202231
maybe_print_startup_update(&cli).await;
203232
let _logging = install_logging(&cli)?;
204233
tracing::info!("default interactive command starting");
205-
let exit = run_agent(/*force_onboarding*/ false, log_level.as_deref(), None).await?;
234+
let exit = run_agent(
235+
/*force_onboarding*/ false,
236+
/*exit_after_onboarding*/ false,
237+
log_level.as_deref(),
238+
None,
239+
)
240+
.await?;
206241
let exit_lines = exit_messages(&exit, /*color_enabled*/ true);
207242
tracing::info!(
208243
line_count = exit_lines.len(),
@@ -339,6 +374,7 @@ mod tests {
339374
use super::cli_logging_overrides;
340375
use super::exit_messages;
341376
use super::format_token_usage_line;
377+
use super::onboarding_exit_messages;
342378

343379
#[test]
344380
fn cli_parses_supported_log_levels() {
@@ -527,6 +563,7 @@ mod tests {
527563
let session_id = SessionId::new();
528564
let exit = devo_tui::AppExit {
529565
session_id: Some(session_id),
566+
onboarding_completed: false,
530567
turn_count: 1,
531568
total_input_tokens: 10,
532569
total_output_tokens: 2,
@@ -549,6 +586,7 @@ mod tests {
549586
let session_id = SessionId::new();
550587
let exit = devo_tui::AppExit {
551588
session_id: Some(session_id),
589+
onboarding_completed: false,
552590
turn_count: 1,
553591
total_input_tokens: 10,
554592
total_output_tokens: 2,
@@ -561,4 +599,65 @@ mod tests {
561599
let lines = exit_messages(&exit, /*color_enabled*/ true);
562600
assert!(lines[1].contains("\u{1b}["));
563601
}
602+
603+
#[test]
604+
fn onboarding_exit_messages_include_next_step_after_success() {
605+
let session_id = SessionId::new();
606+
let exit = devo_tui::AppExit {
607+
session_id: Some(session_id),
608+
onboarding_completed: true,
609+
turn_count: 0,
610+
total_input_tokens: 0,
611+
total_output_tokens: 0,
612+
total_cache_read_tokens: 0,
613+
};
614+
615+
let lines = onboarding_exit_messages(&exit, /*color_enabled*/ false);
616+
617+
assert_eq!(
618+
lines,
619+
vec![
620+
"Configuration complete".to_string(),
621+
String::new(),
622+
"Next step:".to_string(),
623+
" devo".to_string(),
624+
]
625+
);
626+
assert_eq!(lines.iter().any(|line| line.contains("devo resume")), false);
627+
}
628+
629+
#[test]
630+
fn onboarding_exit_messages_are_empty_without_success() {
631+
let session_id = SessionId::new();
632+
let exit = devo_tui::AppExit {
633+
session_id: Some(session_id),
634+
onboarding_completed: false,
635+
turn_count: 0,
636+
total_input_tokens: 0,
637+
total_output_tokens: 0,
638+
total_cache_read_tokens: 0,
639+
};
640+
641+
assert_eq!(
642+
onboarding_exit_messages(&exit, /*color_enabled*/ false),
643+
Vec::<String>::new()
644+
);
645+
}
646+
647+
#[test]
648+
fn colorized_onboarding_exit_messages_include_ansi_sequences() {
649+
let exit = devo_tui::AppExit {
650+
session_id: None,
651+
onboarding_completed: true,
652+
turn_count: 0,
653+
total_input_tokens: 0,
654+
total_output_tokens: 0,
655+
total_cache_read_tokens: 0,
656+
};
657+
658+
let lines = onboarding_exit_messages(&exit, /*color_enabled*/ true);
659+
660+
assert!(lines[0].contains("\u{1b}["));
661+
assert!(lines[3].contains("\u{1b}["));
662+
}
564663
}

crates/server/src/runtime/command_exec.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::collections::HashMap;
2+
use std::collections::HashSet;
23
use std::path::PathBuf;
34
use std::sync::Arc;
45

@@ -15,6 +16,7 @@ use crate::ProtocolErrorCode;
1516
use crate::ServerEvent;
1617
use crate::SuccessResponse;
1718
use crate::runtime::ServerRuntime;
19+
use crate::runtime::connection::SubscriptionFilter;
1820
use devo_protocol::CommandExecExitedPayload;
1921
use devo_protocol::CommandExecOutputDeltaPayload;
2022
use devo_protocol::CommandExecOutputStream;
@@ -341,6 +343,23 @@ impl ServerRuntime {
341343
Ok(cwd) => cwd,
342344
Err((code, message)) => return self.error_response(request_id, code, message),
343345
};
346+
let command_exec_event_types = HashSet::from([
347+
"command/exec/outputDelta".to_string(),
348+
"command/exec/exited".to_string(),
349+
]);
350+
if let Some(connection) = self.connections.lock().await.get_mut(&connection_id) {
351+
let already = connection.subscriptions.iter().any(|subscription| {
352+
subscription.session_id == params.session_id
353+
&& subscription.event_types == command_exec_event_types
354+
});
355+
if !already {
356+
connection.subscriptions.push(SubscriptionFilter {
357+
session_id: params.session_id,
358+
event_types: command_exec_event_types,
359+
include_child_agents: false,
360+
});
361+
}
362+
}
344363
match self
345364
.command_exec_manager
346365
.start(Arc::clone(self), connection_id, params, cwd)

crates/server/tests/command_exec.rs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -298,28 +298,60 @@ async fn wait_for_command_exec_exit(
298298
.await
299299
.context("timed out waiting for command/exec notification")?
300300
.context("notification channel closed before command/exec exited")?;
301-
match notification["method"].as_str() {
302-
Some("command/exec/outputDelta") => {
303-
if notification["params"]["process_id"] != serde_json::json!(process_id) {
301+
let payload = {
302+
let method = notification["method"].as_str();
303+
match method {
304+
Some(method) if method.starts_with("_devo/") => {
305+
let inner_method = method
306+
.strip_prefix("_devo/")
307+
.expect("starts_with checked prefix");
308+
Some((inner_method, &notification["params"]))
309+
}
310+
Some("session/update") => {
311+
let meta = &notification["params"]["_meta"];
312+
let original_method = meta["devo/originalMethod"].as_str();
313+
let original_event = &meta["devo/originalEvent"];
314+
original_method.map(|method| {
315+
let event_payload = if original_event.get("process_id").is_some() {
316+
original_event
317+
} else {
318+
match method {
319+
"command/exec/outputDelta" => {
320+
&original_event["CommandExecOutputDelta"]
321+
}
322+
"command/exec/exited" => &original_event["CommandExecExited"],
323+
_ => original_event,
324+
}
325+
};
326+
(method, event_payload)
327+
})
328+
}
329+
Some(method) => Some((method, &notification["params"])),
330+
None => None,
331+
}
332+
};
333+
match payload {
334+
Some(("command/exec/outputDelta", params)) => {
335+
if params["process_id"] != serde_json::json!(process_id) {
304336
continue;
305337
}
306-
assert_notification_session(&notification["params"], session_id);
307-
assert_eq!(notification["params"]["stream"], "pty");
308-
let delta_base64 = notification["params"]["delta_base64"]
338+
assert_notification_session(params, session_id);
339+
assert_eq!(params["stream"], "pty");
340+
let delta_base64 = params["delta_base64"]
309341
.as_str()
310342
.context("delta_base64 should be a string")?;
311343
let bytes = BASE64_STANDARD.decode(delta_base64)?;
312344
output.push_str(&String::from_utf8_lossy(&bytes));
313345
}
314-
Some("command/exec/exited") => {
315-
if notification["params"]["process_id"] != serde_json::json!(process_id) {
346+
Some(("command/exec/exited", params)) => {
347+
if params["process_id"] != serde_json::json!(process_id) {
316348
continue;
317349
}
318-
assert_notification_session(&notification["params"], session_id);
319-
assert_eq!(notification["params"]["exit_code"], 0);
350+
assert_notification_session(params, session_id);
351+
assert_eq!(params["exit_code"], 0);
320352
return Ok(output);
321353
}
322-
Some("session/started") if session_id.is_none() => {
354+
Some(("session/started", _)) if session_id.is_none() => {
323355
anyhow::bail!("sessionless command/exec unexpectedly created a session")
324356
}
325357
_ => {}

crates/server/tests/end_to_end.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,8 @@ async fn second_stdio_server_process_proxies_to_singleton() -> Result<()> {
430430

431431
#[tokio::test]
432432
async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle() -> Result<()> {
433+
let workspace = TempDir::new()?;
434+
let test_cwd = workspace.path().to_string_lossy().into_owned();
433435
let port = {
434436
let listener = StdTcpListener::bind("127.0.0.1:0")?;
435437
let port = listener.local_addr()?.port();
@@ -485,7 +487,7 @@ async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle()
485487
"id": 2,
486488
"method": "session/new",
487489
"params": {
488-
"cwd": "C:/repo",
490+
"cwd": test_cwd,
489491
"additionalDirectories": [],
490492
"mcpServers": []
491493
}
@@ -515,7 +517,7 @@ async fn websocket_listener_supports_handshake_subscription_and_turn_lifecycle()
515517
.to_string();
516518
assert_eq!(
517519
session_response["result"]["_meta"]["devo/session"]["cwd"],
518-
serde_json::json!("C:/repo")
520+
serde_json::json!(test_cwd)
519521
);
520522

521523
socket

crates/tui/src/app.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ use devo_protocol::SessionId;
1111
pub struct AppExit {
1212
/// Active session identifier at exit, when one exists.
1313
pub session_id: Option<SessionId>,
14+
/// Whether provider onboarding completed successfully during this TUI run.
15+
pub onboarding_completed: bool,
1416
/// Total turns completed in the session.
1517
pub turn_count: usize,
1618
/// Total input tokens accumulated in the session.
@@ -58,6 +60,8 @@ pub struct InteractiveTuiConfig {
5860
pub saved_models: Vec<SavedModelEntry>,
5961
/// Whether to open the model picker on startup.
6062
pub show_model_onboarding: bool,
63+
/// Whether successful onboarding should exit the TUI immediately.
64+
pub exit_after_onboarding: bool,
6165
/// Non-fatal startup warnings to show in the transcript before user input.
6266
pub startup_warnings: Vec<String>,
6367
}

crates/tui/src/app_event.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ pub(crate) enum AppEvent {
2828
/// Request to exit the TUI.
2929
Exit(ExitMode),
3030

31+
/// Provider onboarding completed successfully.
32+
OnboardingCompleted,
33+
3134
/// Submit the current composer text.
3235
SubmitUserInput { text: String },
3336

0 commit comments

Comments
 (0)