diff --git a/crates/forge_app/src/user_prompt.rs b/crates/forge_app/src/user_prompt.rs index 22f542876e..704cb0ea3e 100644 --- a/crates/forge_app/src/user_prompt.rs +++ b/crates/forge_app/src/user_prompt.rs @@ -537,6 +537,31 @@ mod tests { ); } + #[tokio::test] + async fn test_shell_context_added_as_additional_context() { + let agent = fixture_agent_without_user_prompt(); + let shell_context = + "The user's last shell command was: `rm test` (exit status: 1, failure)"; + let event = Event::new("fix it").additional_context(shell_context); + let conversation = fixture_conversation(); + let generator = fixture_generator(agent.clone(), event); + + let actual = generator.add_user_prompt(conversation).await.unwrap(); + + let messages = actual.context.unwrap().messages; + assert_eq!(messages.len(), 2, "Should have user message and shell context"); + + // First message is the user prompt + assert_eq!(messages[0].content().unwrap(), "fix it"); + assert!(!messages[0].is_droppable()); + + // Second message is the shell context (droppable) + let ctx_msg = &messages[1]; + assert!(ctx_msg.content().unwrap().contains("rm test")); + assert!(ctx_msg.content().unwrap().contains("exit status: 1")); + assert!(ctx_msg.is_droppable(), "Shell context should be droppable"); + } + #[tokio::test] async fn test_todos_not_injected_on_new_conversation() { // Setup - Simple mock with no attachments diff --git a/crates/forge_domain/src/event.rs b/crates/forge_domain/src/event.rs index 45256e51b2..b5182c59ac 100644 --- a/crates/forge_domain/src/event.rs +++ b/crates/forge_domain/src/event.rs @@ -219,4 +219,37 @@ mod tests { assert_eq!(context.event.name, "task"); assert_eq!(context.event.value, "initial content"); } + + #[test] + fn test_event_additional_context() { + let event = Event::new("fix it").additional_context("shell context info"); + + assert_eq!( + event.additional_context.as_deref(), + Some("shell context info") + ); + } + + #[test] + fn test_event_additional_context_appended() { + // Simulate piped input as additional context, then shell context appended + let event = Event::new("fix it").additional_context("piped input"); + let combined = match &event.additional_context { + Some(existing) => format!("{existing}\n\nshell context"), + None => "shell context".to_string(), + }; + let event = event.additional_context(combined); + + assert_eq!( + event.additional_context.as_deref(), + Some("piped input\n\nshell context") + ); + } + + #[test] + fn test_event_no_additional_context() { + let event = Event::new("hello"); + + assert!(event.additional_context.is_none()); + } } diff --git a/crates/forge_main/src/cli.rs b/crates/forge_main/src/cli.rs index c3f93a1db0..889575a8db 100644 --- a/crates/forge_main/src/cli.rs +++ b/crates/forge_main/src/cli.rs @@ -90,6 +90,20 @@ pub struct Cli { /// Event to dispatch to the workflow in JSON format. #[arg(long, short = 'e')] pub event: Option, + + /// The last shell command that was executed before invoking forge. + /// + /// Populated by the ZSH plugin's preexec hook to provide context about + /// what the user just ran. Used together with --shell-exit-status. + #[arg(long)] + pub shell_command: Option, + + /// The exit status of the last shell command (0 = success, non-zero = failure). + /// + /// Populated by the ZSH plugin's precmd hook to provide context about + /// whether the previous command succeeded or failed. + #[arg(long)] + pub shell_exit_status: Option, } impl Cli { diff --git a/crates/forge_main/src/ui.rs b/crates/forge_main/src/ui.rs index ad3231cdf1..2fa9413043 100644 --- a/crates/forge_main/src/ui.rs +++ b/crates/forge_main/src/ui.rs @@ -2955,6 +2955,27 @@ impl A + Send + Sync> UI { event = event.additional_context(piped); } + // Inject shell command context when provided by the ZSH plugin. + // This gives the agent context about the last command the user ran + // (e.g., a failed `rm test`) so it can act on `: fix it` prompts. + if let Some(ref shell_cmd) = self.cli.shell_command { + let exit_status = self.cli.shell_exit_status.unwrap_or(0); + let status_label = if exit_status == 0 { + "success" + } else { + "failure" + }; + let shell_context = format!( + "The user's last shell command was: `{shell_cmd}` (exit status: {exit_status}, {status_label})" + ); + // Append to existing additional_context or set it + let combined = match &event.additional_context { + Some(existing) => format!("{existing}\n\n{shell_context}"), + None => shell_context, + }; + event = event.additional_context(combined); + } + // Create the chat request with the event let chat = ChatRequest::new(event, conversation_id); diff --git a/shell-plugin/lib/helpers.zsh b/shell-plugin/lib/helpers.zsh index e0a017282e..725582c78e 100644 --- a/shell-plugin/lib/helpers.zsh +++ b/shell-plugin/lib/helpers.zsh @@ -2,6 +2,24 @@ # Core utility functions for forge plugin +# Shell command context capture via ZSH hooks. +# preexec fires before each command executes - we save the command string. +# precmd fires before each prompt - we save the exit status of the last command. +# These are passed to forge CLI as --shell-command / --shell-exit-status so the +# agent has context about what the user just ran. +function _forge_preexec() { + _FORGE_LAST_COMMAND="$1" +} + +function _forge_precmd() { + _FORGE_LAST_EXIT_STATUS="$?" +} + +# Register hooks using ZSH hook arrays (safe for multiple plugins) +autoload -Uz add-zsh-hook +add-zsh-hook preexec _forge_preexec +add-zsh-hook precmd _forge_precmd + # Lazy loader for commands cache # Loads the commands list only when first needed, avoiding startup cost function _forge_get_commands() { @@ -40,6 +58,10 @@ function _forge_exec_interactive() { cmd=($_FORGE_BIN --agent "$agent_id") [[ -n "$_FORGE_SESSION_MODEL" ]] && cmd+=(--model "$_FORGE_SESSION_MODEL") [[ -n "$_FORGE_SESSION_PROVIDER" ]] && cmd+=(--provider "$_FORGE_SESSION_PROVIDER") + # Pass last shell command context if available + if [[ -n "$_FORGE_LAST_COMMAND" ]]; then + cmd+=(--shell-command "$_FORGE_LAST_COMMAND" --shell-exit-status "$_FORGE_LAST_EXIT_STATUS") + fi cmd+=("$@") "${cmd[@]}" /dev/tty }