Skip to content

Commit 602220e

Browse files
committed
Protect core agent features when hooks are unavailable
Add hookless regression cases for automatic Autofix prompt routing and negative controls, manual fix with auto-suggest disabled, snapshot rendering and resume dispatch without hook rows, ACP chat while listener readiness is pending, and an actual failed-then-recovered listener subprocess delivering a shell error into the Autofix prompt queue. The only new channel constructor is cfg(test); no shipping behavior or dependency changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: c6ef3e46-71af-4e5a-82af-a172700ca3ce
1 parent 4226cd2 commit 602220e

2 files changed

Lines changed: 281 additions & 0 deletions

File tree

tools/wta/src/app_tests.rs

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7881,6 +7881,276 @@ fn vt_event(pane: &str, tab: &str, seq: &str) -> AppEvent {
78817881
}
78827882
}
78837883

7884+
#[test]
7885+
fn hookless_shell_errors_submit_one_correctly_routed_autofix_prompt() {
7886+
let mut app = test_app();
7887+
let (tx, mut prompts) = tokio::sync::mpsc::unbounded_channel();
7888+
app.prompt_tx = tx;
7889+
app.state = ConnectionState::Connected;
7890+
app.autofix_enabled = true;
7891+
app.owner_tab_id = Some("test-tab".into());
7892+
app.window_id = Some("test-window".into());
7893+
app.pane_id = Some("helper-pane".into());
7894+
let pane = "shell-without-hooks";
7895+
assert!(app.agent_sessions.iter_sorted().is_empty());
7896+
7897+
app.handle_event(vt_event(pane, "test-tab", "osc:133;D;0"));
7898+
app.handle_event(vt_event("other-shell", "other-tab", "osc:133;D;1"));
7899+
assert!(
7900+
prompts.try_recv().is_err(),
7901+
"success and other tabs must not submit"
7902+
);
7903+
7904+
app.handle_event(vt_event(pane, "test-tab", "osc:133;D;1"));
7905+
let prompt = prompts
7906+
.try_recv()
7907+
.expect("shell error must reach the ACP prompt queue");
7908+
assert!(prompt.is_autofix());
7909+
let context = prompt.pane_context.expect("autofix must retain its source");
7910+
assert_eq!(context.source_pane_id.as_deref(), Some(pane));
7911+
assert_eq!(context.tab_id.as_deref(), Some("test-tab"));
7912+
assert_eq!(context.window_id.as_deref(), Some("test-window"));
7913+
7914+
app.handle_event(vt_event(pane, "test-tab", "osc:133;A"));
7915+
app.handle_event(vt_event(pane, "test-tab", "osc:133;D;1"));
7916+
assert!(
7917+
prompts.try_recv().is_err(),
7918+
"echo/repeated failure must not double-submit"
7919+
);
7920+
assert_eq!(
7921+
app.tab_mut("test-tab").autofix.pane_id.as_deref(),
7922+
Some(pane)
7923+
);
7924+
assert_eq!(app.state, ConnectionState::Connected);
7925+
assert!(app.agent_sessions.iter_sorted().is_empty());
7926+
}
7927+
7928+
#[test]
7929+
fn hookless_manual_fix_still_submits_when_auto_suggest_is_disabled() {
7930+
let mut app = test_app();
7931+
let (tx, mut prompts) = tokio::sync::mpsc::unbounded_channel();
7932+
app.prompt_tx = tx;
7933+
app.state = ConnectionState::Connected;
7934+
app.autofix_enabled = false;
7935+
app.show_welcome_hint = false;
7936+
bind_test_session(&mut app, "chat-without-hooks");
7937+
7938+
app.cmd_fix(false, "explain the last failure".into());
7939+
7940+
let prompt = prompts
7941+
.try_recv()
7942+
.expect("manual /fix must not require hooks");
7943+
assert!(prompt.is_autofix());
7944+
assert_eq!(prompt.text, "explain the last failure");
7945+
assert!(prompts.try_recv().is_err());
7946+
assert!(app.agent_sessions.iter_sorted().is_empty());
7947+
assert_eq!(app.state, ConnectionState::Connected);
7948+
}
7949+
7950+
#[test]
7951+
fn hookless_session_snapshot_renders_and_dispatches_resume() {
7952+
use crate::agent_sessions::AgentStatus;
7953+
use crate::protocol::acp::client::MasterExtRequest;
7954+
7955+
let _locale = crate::test_support::lock_locale();
7956+
let (mut app, mut requests) = test_app_with_master_rx();
7957+
app.state = ConnectionState::Connected;
7958+
app.current_agent_id = "claude".into();
7959+
app.current_tab_mut().pane_open = true;
7960+
app.current_tab_mut().input = "draft without hooks".into();
7961+
app.current_tab_mut().cursor_pos = app.current_tab().input.len();
7962+
app.open_agents_view_for_tab(DEFAULT_TAB_ID.into());
7963+
let MasterExtRequest::SessionsList { request_id, .. } = requests.try_recv().unwrap() else {
7964+
panic!("opening sessions must request history without any hook");
7965+
};
7966+
let mut row = session_info_for_test("history-without-hooks");
7967+
row.status = Some(AgentStatus::Historical);
7968+
row.cwd = std::env::temp_dir();
7969+
app.handle_event(AppEvent::AgentsSnapshotLoaded {
7970+
request_id,
7971+
sessions: vec![row],
7972+
});
7973+
assert!(
7974+
app.agent_sessions.iter_sorted().is_empty(),
7975+
"history must not need a local hook row"
7976+
);
7977+
assert_eq!(
7978+
app.agents_rows_for_tab(DEFAULT_TAB_ID)[0].key,
7979+
"history-without-hooks"
7980+
);
7981+
assert!(render_to_text(&mut app, 100, 24).contains("history-without-hooks"));
7982+
7983+
app.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
7984+
assert_eq!(app.current_tab().current_view, View::Chat);
7985+
assert_eq!(app.current_tab().input, "draft without hooks");
7986+
app.open_agents_view_for_tab(DEFAULT_TAB_ID.into());
7987+
let MasterExtRequest::SessionsList { request_id, .. } = requests.try_recv().unwrap() else {
7988+
panic!("reopening sessions must request history");
7989+
};
7990+
let mut row = session_info_for_test("history-without-hooks");
7991+
row.status = Some(AgentStatus::Historical);
7992+
row.cwd = std::env::temp_dir();
7993+
app.handle_event(AppEvent::AgentsSnapshotLoaded {
7994+
request_id,
7995+
sessions: vec![row],
7996+
});
7997+
app.current_tab_mut().agents_list_state.select(Some(0));
7998+
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
7999+
let command = app
8000+
.last_dispatched_command_for_test()
8001+
.expect("resume dispatched");
8002+
assert_eq!(command.kind, DispatchedCommandKind::NewTabResume);
8003+
assert!(command
8004+
.argv
8005+
.join(" ")
8006+
.contains("claude --resume history-without-hooks"));
8007+
}
8008+
8009+
#[tokio::test]
8010+
async fn hookless_chat_streams_while_listener_readiness_is_pending() {
8011+
use crate::protocol::acp::client::mock_agent_tests::connect_mock_agent;
8012+
use agent_client_protocol as acp;
8013+
8014+
tokio::task::LocalSet::new()
8015+
.run_until(async {
8016+
// Only this channel uses the missing executable: no PATH, COM
8017+
// registration or user configuration is changed.
8018+
let listener = Arc::new(crate::shell::wt_channel::CliChannel::with_test_executable(
8019+
std::env::temp_dir()
8020+
.join(format!("missing-wtcli-{}.exe", uuid::Uuid::new_v4()))
8021+
.to_string_lossy()
8022+
.into_owned(),
8023+
));
8024+
let mut readiness = Box::pin(listener.start_reader());
8025+
assert!(
8026+
tokio::time::timeout(std::time::Duration::from_millis(50), &mut readiness)
8027+
.await
8028+
.is_err(),
8029+
"the failed listener must actually be waiting to retry"
8030+
);
8031+
8032+
let (conn, mut events, _seen) = connect_mock_agent();
8033+
conn.initialize(acp::schema::v1::InitializeRequest::new(
8034+
acp::schema::ProtocolVersion::LATEST,
8035+
))
8036+
.await
8037+
.unwrap();
8038+
let session = conn
8039+
.new_session(acp::schema::v1::NewSessionRequest::new("/test"))
8040+
.await
8041+
.unwrap();
8042+
let sid = session.session_id.to_string();
8043+
let mut app = test_app();
8044+
app.state = ConnectionState::Connected;
8045+
app.show_welcome_hint = false;
8046+
let (tx, mut prompts) = tokio::sync::mpsc::unbounded_channel();
8047+
app.prompt_tx = tx;
8048+
bind_test_session(&mut app, &sid);
8049+
app.current_tab_mut().input = "hookless-chat".into();
8050+
app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
8051+
let prompt = prompts
8052+
.try_recv()
8053+
.expect("chat submission cannot wait for hooks");
8054+
assert!(!prompt.is_autofix());
8055+
assert_eq!(prompt.text, "hookless-chat");
8056+
tokio::time::timeout(
8057+
std::time::Duration::from_secs(5),
8058+
conn.prompt(acp::schema::v1::PromptRequest::new(
8059+
session.session_id,
8060+
vec![prompt.text.into()],
8061+
)),
8062+
)
8063+
.await
8064+
.expect("chat cannot wait for listener readiness")
8065+
.unwrap();
8066+
pump_until(&mut app, &mut events, |event| {
8067+
matches!(event, AppEvent::AgentMessageChunk { .. })
8068+
})
8069+
.await;
8070+
assert!(app
8071+
.current_tab()
8072+
.active_agent_text()
8073+
.contains("MOCK_OK:hookless-chat"));
8074+
assert!(app.agent_sessions.iter_sorted().is_empty());
8075+
assert_eq!(app.state, ConnectionState::Connected);
8076+
// Drop cancels this test's retry loop rather than leaving it alive.
8077+
drop(readiness);
8078+
drop(listener);
8079+
})
8080+
.await;
8081+
}
8082+
8083+
#[cfg(windows)]
8084+
#[tokio::test]
8085+
async fn hookless_listener_recovery_delivers_shell_error_to_autofix() {
8086+
struct Fixture(std::path::PathBuf);
8087+
impl Drop for Fixture {
8088+
fn drop(&mut self) {
8089+
for name in ["listener.cmd", "attempted"] {
8090+
let _ = std::fs::remove_file(self.0.join(name));
8091+
}
8092+
let _ = std::fs::remove_dir(&self.0);
8093+
}
8094+
}
8095+
let fixture =
8096+
Fixture(std::env::temp_dir().join(format!("wta-listener-{}", uuid::Uuid::new_v4())));
8097+
std::fs::create_dir(&fixture.0).unwrap();
8098+
let executable = fixture.0.join("listener.cmd");
8099+
// First process exits before subscribing. The next emits a readiness
8100+
// marker and an ordinary WT shell error, but never any agent hook.
8101+
std::fs::write(&executable, r#"@echo off
8102+
if exist "%~dp0attempted" goto ready
8103+
echo attempted>"%~dp0attempted"
8104+
exit /b 1
8105+
:ready
8106+
echo {"_wtcli":"listener_ready","token":"%~6"}
8107+
echo {"method":"vt_sequence","params":{"pane_id":"shell-after-recovery","tab_id":"test-tab","sequence":"osc:133;D;1"}}
8108+
exit /b 0
8109+
"#.replace('\n', "\r\n")).unwrap();
8110+
let listener = Arc::new(crate::shell::wt_channel::CliChannel::with_test_executable(
8111+
executable.to_string_lossy().into_owned(),
8112+
));
8113+
let mut events = listener.subscribe_events();
8114+
assert!(
8115+
tokio::time::timeout(std::time::Duration::from_secs(10), listener.start_reader())
8116+
.await
8117+
.expect("listener must recover"),
8118+
"the replacement process must reach subscription readiness"
8119+
);
8120+
let event = tokio::time::timeout(std::time::Duration::from_secs(5), events.recv())
8121+
.await
8122+
.expect("WT event must be delivered")
8123+
.expect("event channel remains open");
8124+
assert_eq!(
8125+
event["method"], "vt_sequence",
8126+
"internal readiness markers must not reach App"
8127+
);
8128+
let params = event["params"].clone();
8129+
let mut app = test_app();
8130+
app.state = ConnectionState::Connected;
8131+
app.autofix_enabled = true;
8132+
app.owner_tab_id = Some("test-tab".into());
8133+
let (tx, mut prompts) = tokio::sync::mpsc::unbounded_channel();
8134+
app.prompt_tx = tx;
8135+
app.handle_event(AppEvent::WtEvent {
8136+
method: event["method"].as_str().unwrap().into(),
8137+
pane_id: params["pane_id"].as_str().unwrap().into(),
8138+
tab_id: Some(params["tab_id"].as_str().unwrap().into()),
8139+
params,
8140+
});
8141+
let prompt = prompts
8142+
.try_recv()
8143+
.expect("recovered event must submit Autofix, not just update a flag");
8144+
assert!(prompt.is_autofix());
8145+
assert_eq!(
8146+
prompt.pane_context.unwrap().source_pane_id.as_deref(),
8147+
Some("shell-after-recovery")
8148+
);
8149+
assert!(app.agent_sessions.iter_sorted().is_empty());
8150+
drop(listener);
8151+
drop(events);
8152+
}
8153+
78848154
/// Detected state must survive the `osc:133;A` that PowerShell emits
78858155
/// ~1ms after the triggering `osc:133;D` — that A is the trigger's
78868156
/// echo, not the user moving on. The NEXT prompt-start (after the

tools/wta/src/shell/wt_channel/cli_channel.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,17 @@ impl Drop for CliChannel {
598598
}
599599

600600
impl CliChannel {
601+
#[cfg(test)]
602+
pub(crate) fn with_test_executable(wtcli_path: String) -> Self {
603+
Self {
604+
available: AtomicBool::new(true),
605+
debug_tx: None,
606+
event_tx: std::sync::Mutex::new(None),
607+
listener_shutdown: std::sync::Mutex::new(None),
608+
wtcli_path,
609+
}
610+
}
611+
601612
pub async fn connect() -> anyhow::Result<Self> {
602613
// WT_COM_CLSID must be set — wtcli reads it from the environment.
603614
if std::env::var("WT_COM_CLSID").is_err() {

0 commit comments

Comments
 (0)