@@ -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
0 commit comments