use std::path::Path; use kigi_workspace::session::git::VcsKind; // Re-export from kigi-chat-state — canonical definition lives there. pub use kigi_chat_state::compaction_utils::extract_user_query; /// Wraps the user query properly pub fn user_query(user_message: String) -> String { format!( r#" {user_message} "# ) } /// Environment info for constructing the `` block. /// /// When `None`, values are read from the local machine. When `Some`, /// the provided values are used (e.g. from a remote workspace via /// `workspace.info` RPC). pub struct UserInfoOverride { pub os: String, pub shell: String, pub cwd: String, } /// Minimal user message prefix for fast-start / headless contexts. /// /// Intentionally excludes workspace snapshot and git status. /// When `override_info` is provided, uses remote workspace info instead /// of local machine introspection. pub fn construct_user_message_minimal( working_directory: &Path, override_info: Option<&UserInfoOverride>, ) -> String { let local_shell; let (os, shell, cwd) = match override_info { Some(info) => (info.os.as_str(), info.shell.as_str(), info.cwd.clone()), None => { local_shell = resolve_shell_display(); ( std::env::consts::OS, local_shell.as_str(), working_directory.to_string_lossy().to_string(), ) } }; // Local-timezone date, captured when the prefix is built. Re-stamped on // compaction and on resume (build_user_message_prefix), so it stays current // across long sessions. let today = chrono::Local::now().format("%Y-%m-%d"); format!( r#" OS Version: {os} Shell: {shell} Workspace Path: {cwd} Today's date: {today} Note: Prefer using relative paths over absolute paths as tool call args when possible. "#, ) } /// Resolve a display string for the user's shell. /// /// Unix: full path from `$SHELL` (e.g. `/bin/zsh`). /// Windows: detected via `detect_windows_shell` cascade /// (pwsh > powershell.exe > Git Bash > cmd.exe). fn resolve_shell_display() -> String { #[cfg(unix)] { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) } #[cfg(not(unix))] { kigi_config::shell::detect_windows_shell() .name() .to_string() } } pub fn format_vcs_status_block(status: &str, vcs_kind: VcsKind) -> String { let (tag, description) = if vcs_kind.is_jj() { ( "jj_status", "This is the Jujutsu (jj) status at the start of the conversation. This is a \ jj-managed repository \u{2014} use `jj` commands instead of `git`. There is no staging \ area; all changes are part of the working-copy commit (@). Use `jj describe` to \ set commit messages and `jj new` to finalize changes.", ) } else { ( "git_status", "This is the git status at the start of the conversation. Note that this status \ is a snapshot in time, and will not update during the conversation.", ) }; format!("\n\n<{tag}>\n{description}\n{status}\n\n") } /// Compute the VCS status block (without the `` wrapper). pub async fn compute_vcs_status_block( working_directory: &Path, vcs_kind: VcsKind, ) -> Option { use kigi_workspace::file_system::{git_status, jj_status}; if matches!(vcs_kind, VcsKind::None) { return None; } let _timer = crate::instrumentation_timer!("session.user_prefix.vcs_status"); let timeout = std::time::Duration::from_secs(2); let result = if vcs_kind.is_jj() { tokio::time::timeout(timeout, jj_status(working_directory)).await } else { tokio::time::timeout(timeout, git_status(working_directory)).await }; match result { Ok(Ok(status)) => Some(format_vcs_status_block(&status, vcs_kind)), Ok(Err(e)) => { tracing::warn!("user prefix VCS status failed: {e}"); None } Err(_) => { tracing::warn!(vcs = ?vcs_kind, "user prefix VCS status timed out after 2s"); None } } } /// Full user message prefix: `` + VCS status. /// /// When `override_info` is provided, uses remote workspace info and /// `vcs_status_override` (pre-fetched from the remote workspace) instead /// of local introspection. pub async fn construct_user_message( working_directory: &Path, vcs_kind: VcsKind, override_info: Option<&UserInfoOverride>, vcs_status_override: Option, ) -> String { let cwd = working_directory.to_string_lossy().to_string(); let vcs_block = if let Some(status) = vcs_status_override { Some(format_vcs_status_block(&status, vcs_kind)) } else { let (block, elapsed_ms) = crate::timed!({ let mut timer = crate::instrumentation_timer!("session.user_prefix"); timer.with_field("cwd", cwd.as_str()); compute_vcs_status_block(working_directory, vcs_kind).await }); tracing::debug!(elapsed_ms = elapsed_ms as u64, "startup: user_prefix"); block }; let mut user_info = construct_user_message_minimal(working_directory, override_info); if let Some(vcs) = vcs_block { user_info.push_str(&vcs); } user_info } // Tests for extract_user_query now live in kigi_chat_state::compaction_utils. #[cfg(test)] mod tests { use super::*; use kigi_workspace::file_system::FsError; /// Verify that construct_user_message completes within the 2s git_status /// timeout even when pointed at a non-existent directory (git commands /// fail instantly → no timeout path exercised, but validates the happy /// path doesn't regress). #[tokio::test] async fn construct_user_message_returns_without_git_status_on_bad_dir() { let dir = std::path::Path::new("/tmp/nonexistent-kigi-test-dir"); let msg = construct_user_message(dir, VcsKind::Git, None, None).await; // user_info block is always present assert!( msg.contains(""), "must contain user_info section" ); // git_status block should be absent (git commands fail on non-repo) assert!( !msg.contains(""), "must not contain git_status for non-repo directory" ); } #[test] fn git_status_error_omits_block() { // Simulate what construct_user_message does when git_status returns Err let git_status_res: Result = Err(FsError::Other("timed out".into())); let mut user_info = "test".to_string(); if let Ok(git_status) = git_status_res { user_info = format!("{user_info}\n{git_status}"); } assert!(!user_info.contains("")); } }