Skip to content
Closed
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
25 changes: 25 additions & 0 deletions crates/forge_app/src/user_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions crates/forge_domain/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
14 changes: 14 additions & 0 deletions crates/forge_main/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,20 @@ pub struct Cli {
/// Event to dispatch to the workflow in JSON format.
#[arg(long, short = 'e')]
pub event: Option<String>,

/// 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<String>,

/// 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<i32>,
}

impl Cli {
Expand Down
21 changes: 21 additions & 0 deletions crates/forge_main/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2955,6 +2955,27 @@ impl<A: API + ConsoleWriter + 'static, F: Fn() -> A + Send + Sync> UI<A, F> {
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);

Expand Down
22 changes: 22 additions & 0 deletions shell-plugin/lib/helpers.zsh
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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 >/dev/tty
}
Expand Down
Loading