Skip to content
Open
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
147 changes: 147 additions & 0 deletions tools/wta/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,21 @@ pub enum AppEvent {
MasterMutationCompleted {
request_id: u64,
},
/// A `Send` action completed and terminal output was captured from the
/// target pane. The App feeds this back to the agent as a follow-up
/// prompt so the agent can observe what happened and continue reasoning
/// (multi-turn command execution loop). Emitted by
/// `coordinator::capture_and_feed_output` after the command runs.
CommandOutputReady {
/// ACP session id that dispatched the original recommendation.
session_id: String,
/// WT pane id that the command was sent to.
pane_id: String,
/// The command text that was sent (used for context in the prompt).
command: String,
/// Captured terminal output (truncated to 4000 chars).
output: String,
},
}

// --- Per-tab session storage ---
Expand Down Expand Up @@ -4414,6 +4429,7 @@ impl App {
AppEvent::AgentsSnapshotFailed { .. } => "agents_snapshot_failed",
AppEvent::MasterMutationCompleted { .. } => "master_mutation_completed",
AppEvent::RevealTick => "reveal_tick",
AppEvent::CommandOutputReady { .. } => "command_output_ready",
}
}

Expand Down Expand Up @@ -5175,6 +5191,14 @@ impl App {
tracing::debug!(target: "agents_view", request_id, "master mutation completed; refetching open views");
self.schedule_agents_refetch_for_open_views();
}
AppEvent::CommandOutputReady {
session_id,
pane_id,
command,
output,
} => {
self.handle_command_output_ready(session_id, pane_id, command, output);
}
AppEvent::WtEvent {
method,
pane_id,
Expand Down Expand Up @@ -7909,6 +7933,55 @@ impl App {

fn push_execution_info(&mut self, _message: String) {}

/// Handle the result of `capture_and_feed_output` from the coordinator.
///
/// Submits a follow-up prompt to the ACP agent so it can observe the
/// terminal output and continue reasoning (multi-turn command execution).
/// The follow-up is a synthesized user message describing what happened.
/// The agent receives the command and its output and can decide next steps.
fn handle_command_output_ready(
&mut self,
session_id: String,
pane_id: String,
command: String,
output: String,
) {
tracing::debug!(
target: "coordinator",
session_id = %session_id,
pane_id = %pane_id,
command_chars = command.chars().count(),
output_chars = output.chars().count(),
"command_output_ready: submitting follow-up turn"
);

// Build a synthesized follow-up prompt with the command output.
// The agent already knows what command it recommended; we give it
// the actual terminal output so it can verify success or diagnose
// issues and continue.
let follow_up_text = format!(
"## Command Output\n\nThe command `{}` was executed in pane `{}`.\n\nHere is the terminal output:\n\n```\n{}\n```\n\nPlease review the output and continue helping the user. If the command succeeded, let me know. If there were any errors or unexpected results, please help diagnose and resolve them.",
Comment on lines +7962 to +7963
command, pane_id, output
);

let pane_context = crate::pane_context::PaneContext {
pane_id: Some(pane_id.clone()),
source_pane_id: Some(pane_id),
..Default::default()
};

let prompt = PromptSubmission::new(follow_up_text, Some(pane_context));
let submitted = SubmittedPrompt {
id: prompt.id,
text: prompt.text.clone(),
submitted_at_unix_s: prompt.submitted_at_unix_s,
autofix: None,
};

self.turn_submit_prompt(&session_id, submitted);
let _ = self.prompt_tx.send(prompt);
}

fn selected_recommendation_choice(&self) -> Option<&RecommendationChoice> {
let tab = self.current_tab();
tab.turn
Expand Down Expand Up @@ -8428,6 +8501,7 @@ impl App {
.send(crate::coordinator::ChoiceExecution {
choice,
insert_only,
session_id: Some(session_id.to_string()),
});
if armed_pane.is_some() {
self.emit_autofix_state_cleared(&target_tab);
Expand Down Expand Up @@ -9435,6 +9509,79 @@ mod tests {
)
}

/// `CommandOutputReady` submits a follow-up prompt to the ACP agent so
/// it can read the terminal output and continue reasoning (multi-turn).
///
/// After a `Send` action the coordinator captures pane output and emits
/// `CommandOutputReady`. The App handler must:
/// 1. Build a synthesized prompt text containing the command + output.
/// 2. Submit it as a new `SubmittedPrompt` via `turn_submit_prompt`.
/// 3. Forward it to the ACP client via `prompt_tx.send`.
#[test]
fn command_output_ready_submits_follow_up_prompt() {
let (prompt_tx, mut prompt_rx) = tokio::sync::mpsc::unbounded_channel();
let (recommendation_tx, _recommendation_rx) = tokio::sync::mpsc::unbounded_channel();
let (permission_tx, _permission_rx) = tokio::sync::mpsc::unbounded_channel();
let (cancel_tx, _cancel_rx) = tokio::sync::mpsc::unbounded_channel();
let (new_session_tx, _new_session_rx) = tokio::sync::mpsc::unbounded_channel();
let (load_session_tx, _load_session_rx) = tokio::sync::mpsc::unbounded_channel();
let (drop_session_tx, _drop_session_rx) = tokio::sync::mpsc::unbounded_channel();
let (rename_session_tx, _rename_session_rx) = tokio::sync::mpsc::unbounded_channel();
let (restart_tx, _restart_rx) = tokio::sync::mpsc::unbounded_channel();
let debug_capture = Arc::new(AtomicBool::new(false));
let (master_tx, _master_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
prompt_tx,
recommendation_tx,
permission_tx,
cancel_tx,
new_session_tx,
load_session_tx,
drop_session_tx,
rename_session_tx,
restart_tx,
master_tx,
debug_capture,
true,
false,
Arc::new(crate::shell::ShellManager::new()),
);

// Wire up the session so `tab_for_session` resolves.
let session_id = "test-session-multi-turn";
app.session_to_tab
.insert(session_id.to_string(), DEFAULT_TAB_ID.to_string());

// Dispatch the event.
app.handle_event(AppEvent::CommandOutputReady {
session_id: session_id.to_string(),
pane_id: "pane-42".to_string(),
command: "cargo build".to_string(),
output: " Compiling foo v0.1.0\n Finished dev [unoptimized] target(s) in 3.1s"
.to_string(),
});

// A PromptSubmission must have been forwarded to the ACP client.
let submission = prompt_rx
.try_recv()
.expect("CommandOutputReady must forward a PromptSubmission to prompt_tx");

// The prompt text must contain the command and the output.
assert!(
submission.text.contains("cargo build"),
"prompt text should reference the command"
);
assert!(
submission.text.contains("Compiling foo"),
"prompt text should include the captured output"
);
// Must not be flagged as an autofix prompt — it's a planner follow-up.
assert!(
!submission.is_autofix,
"multi-turn follow-up should not be an autofix prompt"
);
}

/// Bug-1 fix (PR #73 follow-up): an `agent.notification` hook event
/// arrives with neither `agent_session_id` nor a `pane_session_id`
/// resolving to a live session — exactly the shape Copilot CLI's
Expand Down
1 change: 1 addition & 0 deletions tools/wta/src/app/autofix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -466,6 +466,7 @@ impl App {
.send(crate::coordinator::ChoiceExecution {
choice,
insert_only: false,
session_id: None,
});
}
self.push_execution_info(format!("Auto-executing choice {}.", choice_label));
Expand Down
115 changes: 114 additions & 1 deletion tools/wta/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ pub struct ChoiceExecution {
pub choice: RecommendationChoice,
/// When true, Send actions paste text without a trailing Enter (insert-only).
pub insert_only: bool,
/// ACP session id of the tab that dispatched this choice. When `Some`,
/// the executor will capture terminal output after a `Send` action and
/// emit `AppEvent::CommandOutputReady` so the agent sees what happened
/// and can continue in a follow-up turn (multi-turn command execution).
/// `None` when no session is available (autofix fallback path); no
/// follow-up turn is emitted in that case.
pub session_id: Option<String>,
}

pub fn default_supported_delegate_agents() -> Vec<SupportedDelegateAgent> {
Expand Down Expand Up @@ -329,7 +336,7 @@ pub async fn run_recommendation_executor(
) {
while let Some(exec) = rx.recv().await {
let delegate_agents = delegate_agents.lock().unwrap().clone();
match execute_choice(&exec.choice, exec.insert_only, &shell_mgr, &delegate_agents, &event_tx).await {
match execute_choice(&exec.choice, exec.insert_only, exec.session_id.as_deref(), &shell_mgr, &delegate_agents, &event_tx).await {
Ok(()) => {}
Err(err) => {
let err_str = format!("{:#}", err);
Expand All @@ -349,6 +356,7 @@ pub async fn run_recommendation_executor(
async fn execute_choice(
choice: &RecommendationChoice,
insert_only: bool,
session_id: Option<&str>,
shell_mgr: &ShellManager,
delegate_agents: &[DelegateAgentRuntime],
event_tx: &mpsc::UnboundedSender<AppEvent>,
Expand Down Expand Up @@ -402,6 +410,24 @@ async fn execute_choice(
parent, err
));
}
// Multi-turn feedback loop: after sending a command, wait
// briefly for it to complete then capture pane output and
// route it back to the agent as a follow-up turn. The agent
// can then read what happened and continue problem-solving.
// Only fires when a session_id is available (i.e. a user-
// visible planner/autofix turn, not the lightweight autofix
// fallback path). Best-effort — any failure is logged and
// silently ignored so it never blocks normal execution.
if let Some(sid) = session_id {
capture_and_feed_output(
sid,
parent,
input,
shell_mgr,
event_tx,
)
.await;
}
Comment on lines +421 to +430
}
}
RecommendedAction::OpenAndSend {
Expand Down Expand Up @@ -559,6 +585,93 @@ async fn execute_choice(
Ok(())
}

/// Wait briefly for a shell command to complete, then capture recent pane
/// output and emit `AppEvent::CommandOutputReady` so the agent can see
/// what happened and continue in a follow-up turn.
///
/// Timing: we sleep 1.5 s as a "command probably finished" heuristic.
/// Shell-integration OSC 133 markers let us capture the exact
/// command-plus-output block when available; otherwise we fall back to
/// reading the last 30 lines of the pane buffer.
///
/// Best-effort: any failure is logged and silently swallowed — this must
/// never break normal command execution.
async fn capture_and_feed_output(
session_id: &str,
pane_id: &str,
command: &str,
shell_mgr: &ShellManager,
event_tx: &mpsc::UnboundedSender<AppEvent>,
) {
// Give the command a moment to finish before we read the pane.
sleep(Duration::from_millis(1500)).await;

let output = read_pane_last_command_output(shell_mgr, pane_id).await;

coordinator_log(&format!(
"capture_and_feed_output session={} pane={} command_chars={} output_present={}",
session_id,
pane_id,
command.chars().count(),
output.is_some(),
));

let output = match output {
Some(o) if !o.trim().is_empty() => o,
_ => {
coordinator_log(&format!(
"capture_and_feed_output: no output captured for pane {}, skipping follow-up",
pane_id
));
return;
}
};

let _ = event_tx.send(AppEvent::CommandOutputReady {
session_id: session_id.to_string(),
pane_id: pane_id.to_string(),
command: command.to_string(),
output,
});
}

/// Read the most recent command output from a pane.
/// Prefers shell-integration OSC 133 marks; falls back to the last 30 lines.
async fn read_pane_last_command_output(
shell_mgr: &ShellManager,
pane_id: &str,
) -> Option<String> {
const MAX_CHARS: usize = 4000;

// Try shell-integration first (OSC 133 marks give us the exact last command+output).
if let Ok(value) = shell_mgr.wt_read_last_prompt(pane_id).await {
let has_marks = value
.get("has_marks")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if has_marks {
if let Some(content) = value.get("content").and_then(|c| c.as_str()) {
if !content.is_empty() {
return Some(truncate_for_log(content, MAX_CHARS));
}
}
}
}
Comment on lines +644 to +659

// Fallback: read recent lines from the pane buffer.
shell_mgr
.wt_read_pane_output(pane_id, Some(30))
.await
.ok()
.and_then(|value| {
value
.get("content")
.and_then(|content| content.as_str())
.filter(|s| !s.is_empty())
.map(|content| truncate_for_log(content, MAX_CHARS))
})
}

fn validate_recommendation_set(set: &RecommendationSet) -> Result<()> {
if !(1..=3).contains(&set.choices.len()) {
bail!("expected 1 to 3 choices, got {}", set.choices.len());
Expand Down
Loading