mirror of
https://github.com/khodges42/exoshell.git
synced 2026-06-14 18:08:37 +00:00
970 lines
34 KiB
Rust
970 lines
34 KiB
Rust
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use crate::commands::{CommandSuggestion, parse_command_suggestions};
|
|
use crate::config::Config;
|
|
use crate::context::{
|
|
ContextError, ContextPriority, ContextProviderRegistry, ContextProviderRequest,
|
|
SessionContextStore, budget_warning, prune_context, register_default_context_providers,
|
|
render_context_details, render_context_list, render_context_stats,
|
|
};
|
|
use crate::prompts::{Stance, assemble_prompt, render_prompt_estimate};
|
|
use crate::providers::{ChatMessage, ChatRequest, ChatResponse, ChatRole, Provider, ProviderError};
|
|
use crate::repl::ReplError;
|
|
use crate::shell::ShellFamily;
|
|
use crate::transcripts::{Transcript, TranscriptError};
|
|
|
|
pub struct App {
|
|
config: Config,
|
|
provider: Box<dyn Provider>,
|
|
conversation: Vec<ChatMessage>,
|
|
transcript: Transcript,
|
|
context_store: SessionContextStore,
|
|
context_registry: ContextProviderRegistry,
|
|
last_command_suggestions: Vec<CommandSuggestion>,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(config: Config, provider: Box<dyn Provider>) -> Self {
|
|
let transcript = Transcript::new(
|
|
"openai-compatible".into(),
|
|
config.provider.model.clone(),
|
|
config.shell.family,
|
|
config.interaction.stance,
|
|
);
|
|
let mut context_registry = ContextProviderRegistry::new();
|
|
register_default_context_providers(&mut context_registry)
|
|
.expect("default context providers should register");
|
|
|
|
Self {
|
|
config,
|
|
provider,
|
|
conversation: Vec::new(),
|
|
transcript,
|
|
context_store: SessionContextStore::new(),
|
|
context_registry,
|
|
last_command_suggestions: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub async fn send(&mut self, input: String) -> Result<String, AppError> {
|
|
self.conversation
|
|
.push(ChatMessage::new(ChatRole::User, input.clone()));
|
|
self.transcript.record_user(&input);
|
|
|
|
let request_messages = self.assembled_messages()?;
|
|
let request = ChatRequest {
|
|
messages: request_messages,
|
|
stream: false,
|
|
};
|
|
|
|
let timeout = Duration::from_secs(self.config.provider.request_timeout_seconds);
|
|
let response = match tokio::time::timeout(timeout, self.provider.chat(request)).await {
|
|
Err(_) => {
|
|
let message = format!(
|
|
"provider request timed out after {} seconds",
|
|
self.config.provider.request_timeout_seconds
|
|
);
|
|
self.transcript.record_error(&message);
|
|
return Err(AppError::Provider(ProviderError::Network(message)));
|
|
}
|
|
Ok(Ok(ChatResponse::Complete(response))) => response,
|
|
Ok(Ok(ChatResponse::Stream(chunks))) => chunks.concat(),
|
|
Ok(Err(error)) => {
|
|
self.transcript.record_error(&error.to_string());
|
|
return Err(error.into());
|
|
}
|
|
};
|
|
self.conversation
|
|
.push(ChatMessage::new(ChatRole::Assistant, response.clone()));
|
|
self.transcript.record_assistant(&response);
|
|
self.last_command_suggestions = parse_command_suggestions(&response);
|
|
for suggestion in &self.last_command_suggestions {
|
|
self.transcript.record_command_suggestion(suggestion);
|
|
}
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
pub fn handle_command(&mut self, input: &str) -> Result<String, AppError> {
|
|
let trimmed = input.trim();
|
|
if trimmed == "/context" {
|
|
return Ok(render_context_list(self.context_store.entries()));
|
|
}
|
|
|
|
if trimmed == "/panel" {
|
|
return Ok(self.render_panel());
|
|
}
|
|
|
|
if trimmed == "/help" {
|
|
return Ok(help_overview().into());
|
|
}
|
|
|
|
if let Some(topic) = trimmed.strip_prefix("/help ") {
|
|
return Ok(help_topic(topic.trim()).into());
|
|
}
|
|
|
|
if trimmed == "/context stats" {
|
|
let prompt_estimate = self.prompt_budget_estimate();
|
|
return Ok(render_context_stats(
|
|
self.context_store.stats(),
|
|
self.config.context.budget(),
|
|
) + "\n\nPrompt estimate:\n"
|
|
+ &render_prompt_estimate(prompt_estimate));
|
|
}
|
|
|
|
if trimmed == "/stance" {
|
|
return Ok(self.render_stance_status());
|
|
}
|
|
|
|
if let Some(stance) = trimmed.strip_prefix("/stance ") {
|
|
return self.set_stance(stance.trim());
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/copy ") {
|
|
return self.copy_command(id.trim());
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/explain ") {
|
|
return self.explain_command(id.trim());
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/discard ") {
|
|
return self.discard_command(id.trim());
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/context show ") {
|
|
let id = id.trim();
|
|
let entry = self
|
|
.context_store
|
|
.get(id)
|
|
.ok_or_else(|| ContextError::NotFound(id.to_string()))?;
|
|
return Ok(render_context_details(entry));
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/context remove ") {
|
|
let id = id.trim();
|
|
let entry = self
|
|
.context_store
|
|
.remove(id)
|
|
.ok_or_else(|| ContextError::NotFound(id.to_string()))?;
|
|
self.transcript
|
|
.record_context_event("remove", &entry, "removed from session context");
|
|
return Ok(format!("removed {}", entry.id));
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/context enable ") {
|
|
return self.set_enabled(id.trim(), true);
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/context disable ") {
|
|
return self.set_enabled(id.trim(), false);
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/context pin ") {
|
|
return self.set_pinned(id.trim(), true);
|
|
}
|
|
|
|
if let Some(id) = trimmed.strip_prefix("/context unpin ") {
|
|
return self.set_pinned(id.trim(), false);
|
|
}
|
|
|
|
if let Some(rest) = trimmed.strip_prefix("/context priority ") {
|
|
let (id, priority) = parse_context_priority_args(rest)?;
|
|
self.context_store.set_priority(id, priority)?;
|
|
let entry = self
|
|
.context_store
|
|
.get(id)
|
|
.ok_or_else(|| ContextError::NotFound(id.to_string()))?;
|
|
self.transcript.record_context_event(
|
|
"priority",
|
|
entry,
|
|
&format!("priority set to {priority}"),
|
|
);
|
|
return Ok(format!("{} priority: {}", id, priority));
|
|
}
|
|
|
|
if let Some(content) = trimmed.strip_prefix("/add-note ") {
|
|
return self.add_context(
|
|
"manual",
|
|
ContextProviderRequest {
|
|
content: Some(content.trim().to_string()),
|
|
..ContextProviderRequest::default()
|
|
},
|
|
);
|
|
}
|
|
|
|
if let Some(path) = trimmed.strip_prefix("/add-file ") {
|
|
return self.add_context(
|
|
"file",
|
|
ContextProviderRequest {
|
|
path: Some(PathBuf::from(path.trim())),
|
|
..ContextProviderRequest::default()
|
|
},
|
|
);
|
|
}
|
|
|
|
if let Some(path) = trimmed.strip_prefix("/add-dir ") {
|
|
return self.add_context(
|
|
"directory_summary",
|
|
ContextProviderRequest {
|
|
path: Some(PathBuf::from(path.trim())),
|
|
..ContextProviderRequest::default()
|
|
},
|
|
);
|
|
}
|
|
|
|
Err(ContextError::InvalidInput(format!("unknown context command: {trimmed}")).into())
|
|
}
|
|
|
|
pub fn add_command_output(
|
|
&mut self,
|
|
stdout: String,
|
|
command: Option<String>,
|
|
exit_code: Option<i32>,
|
|
) -> Result<String, AppError> {
|
|
self.add_context(
|
|
"command_output",
|
|
ContextProviderRequest {
|
|
stdout: Some(stdout),
|
|
command,
|
|
exit_code,
|
|
cwd: std::env::current_dir().ok(),
|
|
..ContextProviderRequest::default()
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn add_stdin_context(&mut self, content: String) -> Result<String, AppError> {
|
|
self.add_context(
|
|
"stdin",
|
|
ContextProviderRequest {
|
|
content: Some(content),
|
|
cwd: std::env::current_dir().ok(),
|
|
..ContextProviderRequest::default()
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn add_note_context(&mut self, content: String) -> Result<String, AppError> {
|
|
self.add_context(
|
|
"manual",
|
|
ContextProviderRequest {
|
|
content: Some(content),
|
|
..ContextProviderRequest::default()
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn add_file_context(&mut self, path: PathBuf) -> Result<String, AppError> {
|
|
self.add_context(
|
|
"file",
|
|
ContextProviderRequest {
|
|
path: Some(path),
|
|
..ContextProviderRequest::default()
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn save_transcript(&self) -> Result<Option<PathBuf>, AppError> {
|
|
if !self.config.transcript.enabled {
|
|
return Ok(None);
|
|
}
|
|
|
|
let path = self
|
|
.transcript
|
|
.write_to_dir(&self.config.transcript.directory)?;
|
|
Ok(Some(path))
|
|
}
|
|
|
|
fn assembled_messages(&mut self) -> Result<Vec<ChatMessage>, AppError> {
|
|
let size = self.context_store.total_size();
|
|
let budget = self.config.context.budget();
|
|
if budget.is_over_budget(size) {
|
|
let prune_result = prune_context(self.context_store.entries(), budget);
|
|
let warning = budget_warning(size, budget, &prune_result);
|
|
self.transcript.record_budget_warning(&warning);
|
|
return Err(ContextError::TooLarge(warning).into());
|
|
}
|
|
|
|
Ok(assemble_prompt(
|
|
self.config.shell.family,
|
|
self.config.interaction.stance,
|
|
&self.conversation,
|
|
self.context_store.entries(),
|
|
budget,
|
|
)
|
|
.messages)
|
|
}
|
|
|
|
fn prompt_budget_estimate(&self) -> crate::prompts::PromptBudgetEstimate {
|
|
assemble_prompt(
|
|
self.config.shell.family,
|
|
self.config.interaction.stance,
|
|
&self.conversation,
|
|
self.context_store.entries(),
|
|
self.config.context.budget(),
|
|
)
|
|
.estimate
|
|
}
|
|
|
|
pub fn stance(&self) -> Stance {
|
|
self.config.interaction.stance
|
|
}
|
|
|
|
fn render_stance_status(&self) -> String {
|
|
format!(
|
|
"current stance: {}\navailable stances: {}",
|
|
self.config.interaction.stance,
|
|
Stance::names()
|
|
)
|
|
}
|
|
|
|
fn render_panel(&self) -> String {
|
|
format!(
|
|
"Exoshell session\nstance: {}\nshell: {}\nprovider: openai-compatible\nmodel: {}\ntranscript: {}\n\nContext\n{}\n\nPrompt estimate\n{}",
|
|
self.config.interaction.stance,
|
|
self.config.shell.family,
|
|
self.config.provider.model,
|
|
if self.config.transcript.enabled {
|
|
"enabled"
|
|
} else {
|
|
"disabled"
|
|
},
|
|
render_context_list(self.context_store.entries()),
|
|
render_prompt_estimate(self.prompt_budget_estimate())
|
|
)
|
|
}
|
|
|
|
fn set_stance(&mut self, input: &str) -> Result<String, AppError> {
|
|
let stance = input
|
|
.parse::<Stance>()
|
|
.map_err(|error| crate::config::ConfigError::Invalid(error.to_string()))?;
|
|
let previous = self.config.interaction.stance;
|
|
self.config.interaction.stance = stance;
|
|
self.transcript.record_stance_change(previous, stance);
|
|
Ok(format!("stance: {stance}"))
|
|
}
|
|
|
|
fn copy_command(&mut self, id: &str) -> Result<String, AppError> {
|
|
let command = self.command_suggestion(id)?.command.clone();
|
|
self.transcript.record_command_action(
|
|
id,
|
|
"copy",
|
|
"printed command; clipboard support unavailable",
|
|
);
|
|
Ok(format!("clipboard unavailable; command {id}:\n{}", command))
|
|
}
|
|
|
|
fn explain_command(&mut self, id: &str) -> Result<String, AppError> {
|
|
let suggestion = self.command_suggestion(id)?.clone();
|
|
let mut explanation = format!(
|
|
"{} is a {} command suggestion.\n\nCommand:\n{}\n\n",
|
|
suggestion.id, suggestion.shell, suggestion.command
|
|
);
|
|
if let Some(summary) = &suggestion.explanation {
|
|
explanation.push_str(&format!("Model note: {summary}\n"));
|
|
}
|
|
if let Some(warning) = suggestion.detected_risk.warning() {
|
|
explanation.push_str(&format!("{warning}\n"));
|
|
} else {
|
|
explanation
|
|
.push_str("No obvious destructive pattern was detected. Review before running.\n");
|
|
}
|
|
self.transcript
|
|
.record_command_action(id, "explain", "operator requested explanation");
|
|
Ok(explanation.trim_end().to_string())
|
|
}
|
|
|
|
fn discard_command(&mut self, id: &str) -> Result<String, AppError> {
|
|
{
|
|
let suggestion = self.command_suggestion_mut(id)?;
|
|
suggestion.discarded = true;
|
|
}
|
|
self.transcript
|
|
.record_command_action(id, "discard", "operator discarded suggestion");
|
|
Ok(format!("{id} discarded"))
|
|
}
|
|
|
|
fn command_suggestion(&self, id: &str) -> Result<&CommandSuggestion, AppError> {
|
|
self.last_command_suggestions
|
|
.iter()
|
|
.find(|suggestion| suggestion.id == id)
|
|
.ok_or_else(|| ContextError::NotFound(format!("command suggestion '{id}'")).into())
|
|
}
|
|
|
|
fn command_suggestion_mut(&mut self, id: &str) -> Result<&mut CommandSuggestion, AppError> {
|
|
self.last_command_suggestions
|
|
.iter_mut()
|
|
.find(|suggestion| suggestion.id == id)
|
|
.ok_or_else(|| ContextError::NotFound(format!("command suggestion '{id}'")).into())
|
|
}
|
|
|
|
fn add_context(
|
|
&mut self,
|
|
provider_name: &str,
|
|
request: ContextProviderRequest,
|
|
) -> Result<String, AppError> {
|
|
let provider = self
|
|
.context_registry
|
|
.get(provider_name)
|
|
.ok_or_else(|| ContextError::NotFound(format!("context provider '{provider_name}'")))?;
|
|
let entry = provider.collect(request)?;
|
|
let id = self.context_store.add(entry);
|
|
let entry = self
|
|
.context_store
|
|
.get(&id)
|
|
.ok_or_else(|| ContextError::NotFound(id.clone()))?;
|
|
self.transcript
|
|
.record_context_event("add", entry, "added to session context");
|
|
Ok(format!("added {} ({})", entry.id, entry.title))
|
|
}
|
|
|
|
fn set_enabled(&mut self, id: &str, enabled: bool) -> Result<String, AppError> {
|
|
self.context_store.set_enabled(id, enabled)?;
|
|
let entry = self
|
|
.context_store
|
|
.get(id)
|
|
.ok_or_else(|| ContextError::NotFound(id.to_string()))?;
|
|
self.transcript.record_context_event(
|
|
if enabled { "enable" } else { "disable" },
|
|
entry,
|
|
if enabled {
|
|
"enabled for model requests"
|
|
} else {
|
|
"disabled for model requests"
|
|
},
|
|
);
|
|
Ok(format!(
|
|
"{} {}",
|
|
id,
|
|
if enabled { "enabled" } else { "disabled" }
|
|
))
|
|
}
|
|
|
|
fn set_pinned(&mut self, id: &str, pinned: bool) -> Result<String, AppError> {
|
|
self.context_store.set_pinned(id, pinned)?;
|
|
let entry = self
|
|
.context_store
|
|
.get(id)
|
|
.ok_or_else(|| ContextError::NotFound(id.to_string()))?;
|
|
self.transcript.record_context_event(
|
|
if pinned { "pin" } else { "unpin" },
|
|
entry,
|
|
if pinned {
|
|
"pinned for pruning"
|
|
} else {
|
|
"unpinned for pruning"
|
|
},
|
|
);
|
|
Ok(format!(
|
|
"{} {}",
|
|
id,
|
|
if pinned { "pinned" } else { "unpinned" }
|
|
))
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum AppError {
|
|
#[error(transparent)]
|
|
Config(#[from] crate::config::ConfigError),
|
|
#[error(transparent)]
|
|
Provider(#[from] ProviderError),
|
|
#[error(transparent)]
|
|
Repl(#[from] ReplError),
|
|
#[error(transparent)]
|
|
Transcript(#[from] TranscriptError),
|
|
#[error(transparent)]
|
|
Context(#[from] ContextError),
|
|
}
|
|
|
|
fn parse_context_priority_args(input: &str) -> Result<(&str, ContextPriority), ContextError> {
|
|
let mut parts = input.split_whitespace();
|
|
let id = parts
|
|
.next()
|
|
.ok_or_else(|| ContextError::InvalidInput("context ID is required".into()))?;
|
|
let priority = parts
|
|
.next()
|
|
.ok_or_else(|| ContextError::InvalidInput("priority is required".into()))?
|
|
.parse::<ContextPriority>()?;
|
|
if parts.next().is_some() {
|
|
return Err(ContextError::InvalidInput(
|
|
"too many arguments for context priority".into(),
|
|
));
|
|
}
|
|
Ok((id, priority))
|
|
}
|
|
|
|
fn help_overview() -> &'static str {
|
|
"Commands:
|
|
/context list attached context
|
|
/context stats show context and prompt budget estimates
|
|
/context show <id> inspect a context entry
|
|
/context enable|disable <id> control model inclusion
|
|
/context pin|unpin <id> control pruning preference
|
|
/context priority <id> <value> set low, normal, high, or critical priority
|
|
/add-note <text> attach manual context
|
|
/add-file <path> attach a UTF-8 file
|
|
/add-dir <path> attach a shallow directory summary
|
|
/add-output paste command output as explicit context
|
|
/stance [name] show or set operator, audit, teach, or quiet
|
|
/copy <cmd-id> print a suggested command; does not execute it
|
|
/explain <cmd-id> explain a suggested command
|
|
/discard <cmd-id> mark a suggested command as discarded
|
|
/panel show session, stance, provider, and context state
|
|
/multi enter multi-line input
|
|
/exit quit and write transcript if enabled
|
|
|
|
Exoshell suggests commands. It does not execute them."
|
|
}
|
|
|
|
fn help_topic(topic: &str) -> &'static str {
|
|
match topic {
|
|
"context" => {
|
|
"Context is explicit and session-scoped. Use /add-note, /add-file, /add-dir, or /add-output to attach material. Use /context stats before requests to inspect attached context size and prompt estimates."
|
|
}
|
|
"stance" => {
|
|
"Stances change the compact prompt fragment used for the next request: operator is concise and action-oriented, audit focuses on risks, teach explains more, and quiet minimizes prose while keeping safety warnings."
|
|
}
|
|
"commands" => {
|
|
"Suggested commands appear as fenced shell blocks and get IDs such as cmd-001. Use /copy, /explain, or /discard by ID. Copy prints the command when clipboard support is unavailable and never runs it."
|
|
}
|
|
_ => "Unknown help topic. Try /help context, /help stance, or /help commands.",
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Default, PartialEq, Eq)]
|
|
pub struct CliOptions {
|
|
pub config_path: Option<PathBuf>,
|
|
pub shell_family: Option<ShellFamily>,
|
|
pub stance: Option<Stance>,
|
|
pub transcript_enabled: Option<bool>,
|
|
pub transcript_directory: Option<PathBuf>,
|
|
pub context_notes: Vec<String>,
|
|
pub context_files: Vec<PathBuf>,
|
|
pub no_color: bool,
|
|
pub show_help: bool,
|
|
}
|
|
|
|
impl CliOptions {
|
|
pub fn parse<I>(args: I) -> Result<Self, AppError>
|
|
where
|
|
I: IntoIterator<Item = String>,
|
|
{
|
|
let mut options = CliOptions::default();
|
|
let mut args = args.into_iter();
|
|
|
|
while let Some(arg) = args.next() {
|
|
match arg.as_str() {
|
|
"-h" | "--help" => options.show_help = true,
|
|
"--config" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid("--config requires a path".into())
|
|
})?;
|
|
options.config_path = Some(PathBuf::from(value));
|
|
}
|
|
"--shell" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid("--shell requires a value".into())
|
|
})?;
|
|
options.shell_family = Some(value.parse().map_err(
|
|
|error: crate::shell::ShellFamilyError| {
|
|
crate::config::ConfigError::Invalid(error.to_string())
|
|
},
|
|
)?);
|
|
}
|
|
"--stance" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid("--stance requires a value".into())
|
|
})?;
|
|
options.stance = Some(value.parse().map_err(
|
|
|error: crate::prompts::StanceError| {
|
|
crate::config::ConfigError::Invalid(error.to_string())
|
|
},
|
|
)?);
|
|
}
|
|
"--no-transcript" => options.transcript_enabled = Some(false),
|
|
"--transcript-dir" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid(
|
|
"--transcript-dir requires a path".into(),
|
|
)
|
|
})?;
|
|
options.transcript_directory = Some(PathBuf::from(value));
|
|
options.transcript_enabled = Some(true);
|
|
}
|
|
"--context-note" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid("--context-note requires text".into())
|
|
})?;
|
|
options.context_notes.push(value);
|
|
}
|
|
"--context-file" => {
|
|
let value = args.next().ok_or_else(|| {
|
|
crate::config::ConfigError::Invalid("--context-file requires a path".into())
|
|
})?;
|
|
options.context_files.push(PathBuf::from(value));
|
|
}
|
|
"--no-color" => options.no_color = true,
|
|
other => {
|
|
return Err(crate::config::ConfigError::Invalid(format!(
|
|
"unknown argument: {other}"
|
|
))
|
|
.into());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(options)
|
|
}
|
|
|
|
pub fn help() -> &'static str {
|
|
"Usage: exoshell [--config <path>] [--shell powershell|posix] [--stance operator|audit|teach|quiet] [--context-note <text>] [--context-file <path>] [--no-transcript] [--transcript-dir <path>] [--no-color]\n\nStarts the Exoshell interactive model chat. Exoshell suggests commands; it does not execute them."
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::{InteractionConfig, ProviderConfig, ShellConfig, TranscriptConfig};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
#[test]
|
|
fn parses_config_path() {
|
|
let options = CliOptions::parse(["--config".to_string(), "config.toml".to_string()])
|
|
.expect("options parse");
|
|
|
|
assert_eq!(options.config_path, Some(PathBuf::from("config.toml")));
|
|
}
|
|
|
|
#[test]
|
|
fn help_flag_is_supported() {
|
|
let options = CliOptions::parse(["--help".to_string()]).expect("options parse");
|
|
|
|
assert!(options.show_help);
|
|
}
|
|
|
|
#[test]
|
|
fn parses_phase1_cli_overrides() {
|
|
let options = CliOptions::parse([
|
|
"--shell".to_string(),
|
|
"posix".to_string(),
|
|
"--stance".to_string(),
|
|
"audit".to_string(),
|
|
"--no-transcript".to_string(),
|
|
"--transcript-dir".to_string(),
|
|
"out".to_string(),
|
|
"--context-note".to_string(),
|
|
"note".to_string(),
|
|
"--context-file".to_string(),
|
|
"Cargo.toml".to_string(),
|
|
"--no-color".to_string(),
|
|
])
|
|
.expect("options parse");
|
|
|
|
assert_eq!(options.shell_family, Some(ShellFamily::Posix));
|
|
assert_eq!(options.stance, Some(Stance::Audit));
|
|
assert_eq!(options.transcript_enabled, Some(true));
|
|
assert_eq!(options.transcript_directory, Some(PathBuf::from("out")));
|
|
assert_eq!(options.context_notes, vec!["note".to_string()]);
|
|
assert_eq!(options.context_files, vec![PathBuf::from("Cargo.toml")]);
|
|
assert!(options.no_color);
|
|
}
|
|
|
|
#[test]
|
|
fn app_registers_default_context_providers_on_startup() {
|
|
let app = App::new(test_config(), Box::new(NoopProvider));
|
|
|
|
let provider_names: Vec<String> = app
|
|
.context_registry
|
|
.list()
|
|
.into_iter()
|
|
.map(|metadata| metadata.name)
|
|
.collect();
|
|
|
|
assert_eq!(
|
|
provider_names,
|
|
vec![
|
|
"manual".to_string(),
|
|
"file".to_string(),
|
|
"command_output".to_string(),
|
|
"stdin".to_string(),
|
|
"directory_summary".to_string()
|
|
]
|
|
);
|
|
assert_eq!(app.context_store.total_size().characters, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn context_commands_add_and_mutate_entries() {
|
|
let mut app = App::new(test_config(), Box::new(NoopProvider));
|
|
|
|
assert_eq!(
|
|
app.handle_command("/add-note inspect Cargo.toml")
|
|
.expect("add note"),
|
|
"added ctx-001 (manual context)"
|
|
);
|
|
assert!(
|
|
app.handle_command("/context")
|
|
.expect("list")
|
|
.contains("ctx-001")
|
|
);
|
|
assert!(
|
|
app.handle_command("/context show ctx-001")
|
|
.expect("show")
|
|
.contains("inspect Cargo.toml")
|
|
);
|
|
assert_eq!(
|
|
app.handle_command("/context priority ctx-001 high")
|
|
.expect("priority"),
|
|
"ctx-001 priority: high"
|
|
);
|
|
assert_eq!(
|
|
app.handle_command("/context disable ctx-001")
|
|
.expect("disable"),
|
|
"ctx-001 disabled"
|
|
);
|
|
assert_eq!(
|
|
app.handle_command("/context pin ctx-001").expect("pin"),
|
|
"ctx-001 pinned"
|
|
);
|
|
assert!(
|
|
app.handle_command("/context stats")
|
|
.expect("stats")
|
|
.contains("total_entries: 1")
|
|
);
|
|
assert!(
|
|
app.handle_command("/context stats")
|
|
.expect("stats")
|
|
.contains("Prompt estimate:")
|
|
);
|
|
assert_eq!(
|
|
app.handle_command("/context remove ctx-001")
|
|
.expect("remove"),
|
|
"removed ctx-001"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn stance_command_shows_and_changes_current_stance() {
|
|
let mut app = App::new(test_config(), Box::new(NoopProvider));
|
|
|
|
assert_eq!(app.stance(), Stance::Operator);
|
|
assert!(
|
|
app.handle_command("/stance")
|
|
.expect("stance")
|
|
.contains("operator, audit, teach, quiet")
|
|
);
|
|
assert_eq!(
|
|
app.handle_command("/stance audit").expect("set stance"),
|
|
"stance: audit"
|
|
);
|
|
assert_eq!(app.stance(), Stance::Audit);
|
|
assert!(app.handle_command("/stance unknown").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn enabled_context_is_inserted_before_current_user_prompt() {
|
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
let provider = CapturingProvider { seen: seen.clone() };
|
|
let mut app = App::new(test_config(), Box::new(provider));
|
|
app.handle_command("/add-note repo uses cargo")
|
|
.expect("add note");
|
|
|
|
app.send("what should I inspect?".into())
|
|
.await
|
|
.expect("send");
|
|
|
|
let messages = seen.lock().expect("seen lock").clone();
|
|
assert_eq!(
|
|
messages.last().expect("last").content,
|
|
"what should I inspect?"
|
|
);
|
|
assert!(
|
|
messages
|
|
.iter()
|
|
.any(|message| message.content.contains("[Context: ctx-001]")
|
|
&& message.content.contains("repo uses cargo"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn disabled_context_is_omitted_from_provider_request() {
|
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
let provider = CapturingProvider { seen: seen.clone() };
|
|
let mut app = App::new(test_config(), Box::new(provider));
|
|
app.handle_command("/add-note hidden context")
|
|
.expect("add note");
|
|
app.handle_command("/context disable ctx-001")
|
|
.expect("disable");
|
|
|
|
app.send("hello".into()).await.expect("send");
|
|
|
|
let messages = seen.lock().expect("seen lock").clone();
|
|
assert!(
|
|
!messages
|
|
.iter()
|
|
.any(|message| message.content.contains("hidden context"))
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn command_suggestion_actions_use_last_response_ids() {
|
|
let mut app = App::new(
|
|
test_config(),
|
|
Box::new(StaticProvider {
|
|
response: "Inspect:\n```powershell\nGet-ChildItem\n```".into(),
|
|
}),
|
|
);
|
|
|
|
app.send("suggest a command".into()).await.expect("send");
|
|
|
|
assert!(
|
|
app.handle_command("/copy cmd-001")
|
|
.expect("copy")
|
|
.contains("Get-ChildItem")
|
|
);
|
|
assert!(
|
|
app.handle_command("/explain cmd-001")
|
|
.expect("explain")
|
|
.contains("powershell")
|
|
);
|
|
assert_eq!(
|
|
app.handle_command("/discard cmd-001").expect("discard"),
|
|
"cmd-001 discarded"
|
|
);
|
|
assert!(app.handle_command("/copy cmd-999").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn panel_and_help_render_phase2_controls() {
|
|
let mut app = App::new(test_config(), Box::new(NoopProvider));
|
|
app.handle_command("/add-note repo uses cargo")
|
|
.expect("add note");
|
|
|
|
assert!(
|
|
app.handle_command("/panel")
|
|
.expect("panel")
|
|
.contains("stance: operator")
|
|
);
|
|
assert!(
|
|
app.handle_command("/help commands")
|
|
.expect("help")
|
|
.contains("/copy")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn over_budget_context_fails_before_provider_request() {
|
|
let seen = Arc::new(Mutex::new(Vec::new()));
|
|
let provider = CapturingProvider { seen: seen.clone() };
|
|
let mut config = test_config();
|
|
config.context.max_characters = Some(3);
|
|
let mut app = App::new(config, Box::new(provider));
|
|
app.handle_command("/add-note too much context")
|
|
.expect("add note");
|
|
|
|
let error = app
|
|
.send("hello".into())
|
|
.await
|
|
.expect_err("budget should stop request");
|
|
|
|
assert!(error.to_string().contains("context budget exceeded"));
|
|
assert!(seen.lock().expect("seen lock").is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn provider_request_times_out() {
|
|
let mut config = test_config();
|
|
config.provider.request_timeout_seconds = 0;
|
|
let mut app = App::new(config, Box::new(SlowProvider));
|
|
|
|
let error = app
|
|
.send("hello".into())
|
|
.await
|
|
.expect_err("request should time out");
|
|
|
|
assert!(error.to_string().contains("timed out"));
|
|
}
|
|
|
|
#[test]
|
|
fn stdin_context_uses_default_provider_path() {
|
|
let mut app = App::new(test_config(), Box::new(NoopProvider));
|
|
|
|
let message = app
|
|
.add_stdin_context("from pipe".into())
|
|
.expect("stdin context");
|
|
|
|
assert_eq!(message, "added ctx-001 (piped stdin)");
|
|
let entry = app.context_store.get("ctx-001").expect("entry");
|
|
assert_eq!(entry.provenance.origin.to_string(), "stdin");
|
|
assert_eq!(entry.content, "from pipe");
|
|
}
|
|
|
|
struct NoopProvider;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Provider for NoopProvider {
|
|
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
|
|
Ok(ChatResponse::Complete("noop".into()))
|
|
}
|
|
}
|
|
|
|
struct CapturingProvider {
|
|
seen: Arc<Mutex<Vec<ChatMessage>>>,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Provider for CapturingProvider {
|
|
async fn chat(&self, request: ChatRequest) -> Result<ChatResponse, ProviderError> {
|
|
*self.seen.lock().expect("seen lock") = request.messages;
|
|
Ok(ChatResponse::Complete("captured".into()))
|
|
}
|
|
}
|
|
|
|
struct SlowProvider;
|
|
|
|
#[async_trait::async_trait]
|
|
impl Provider for SlowProvider {
|
|
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
|
|
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
Ok(ChatResponse::Complete("late".into()))
|
|
}
|
|
}
|
|
|
|
struct StaticProvider {
|
|
response: String,
|
|
}
|
|
|
|
#[async_trait::async_trait]
|
|
impl Provider for StaticProvider {
|
|
async fn chat(&self, _request: ChatRequest) -> Result<ChatResponse, ProviderError> {
|
|
Ok(ChatResponse::Complete(self.response.clone()))
|
|
}
|
|
}
|
|
|
|
fn test_config() -> Config {
|
|
Config {
|
|
provider: ProviderConfig {
|
|
base_url: "http://localhost:11434/v1".into(),
|
|
api_key: "test-key".into(),
|
|
api_key_env: "EXOSHELL_TEST_KEY".into(),
|
|
model: "test-model".into(),
|
|
request_timeout_seconds: 120,
|
|
},
|
|
shell: ShellConfig {
|
|
family: ShellFamily::PowerShell,
|
|
},
|
|
interaction: InteractionConfig {
|
|
stance: Stance::Operator,
|
|
},
|
|
transcript: TranscriptConfig {
|
|
directory: PathBuf::from("transcripts"),
|
|
enabled: false,
|
|
},
|
|
context: crate::config::ContextConfig::default(),
|
|
}
|
|
}
|
|
}
|