From d308b13e75c3dfb9c27c91a2dcf33e515f275c38 Mon Sep 17 00:00:00 2001 From: Max Novich Date: Tue, 30 Jun 2026 14:15:46 -0700 Subject: [PATCH] [codex] Add SessionStart hook parity outside CLI (#9970) Co-authored-by: Max Novich --- crates/goose-cli/src/session/mod.rs | 7 --- crates/goose/src/agents/agent.rs | 86 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 73dfe7272..d3565f902 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -487,10 +487,6 @@ impl CliSession { /// Start an interactive session, optionally with an initial message pub async fn interactive(&mut self, prompt: Option) -> Result<()> { - self.agent - .emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id) - .await; - let result = self.run_interactive(prompt).await; self.agent @@ -1139,9 +1135,6 @@ impl CliSession { /// Process a single message and exit pub async fn headless(&mut self, prompt: String) -> Result<()> { - self.agent - .emit_hook(goose::hooks::HookEvent::SessionStart, &self.session_id) - .await; let message = Message::user().with_text(&prompt); let result = self .process_message(message, CancellationToken::default(), false) diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 741b06aa9..d4a8f592e 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -1569,6 +1569,19 @@ impl Agent { let message_text = user_message.as_concat_text(); + let session = session_manager + .get_session(&session_config.id, true) + .await?; + let is_first_turn = session + .conversation + .as_ref() + .map(|conversation| conversation.messages().is_empty()) + .unwrap_or(true); + if is_first_turn { + self.emit_hook(crate::hooks::HookEvent::SessionStart, &session_config.id) + .await; + } + if self .hook_manager .has_hooks(crate::hooks::HookEvent::UserPromptSubmit) @@ -3482,6 +3495,64 @@ exit 0 } } + struct SessionStartHookTestEnv { + temp_dir: TempDir, + hook_log: PathBuf, + } + + impl SessionStartHookTestEnv { + fn new() -> Result { + let temp_dir = tempfile::tempdir()?; + let plugin_dir = temp_dir.path().join("session-start"); + std::fs::create_dir_all(plugin_dir.join("hooks"))?; + std::fs::write( + plugin_dir.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { "type": "command", "command": "sh ${PLUGIN_ROOT}/start.sh" } + ] + } + ] + } +} +"#, + )?; + std::fs::write( + plugin_dir.join("start.sh"), + r#"#!/bin/sh +echo start >> "$PLUGIN_ROOT/hook.log" +"#, + )?; + + Ok(Self { + temp_dir, + hook_log: plugin_dir.join("hook.log"), + }) + } + + fn hook_manager(&self) -> crate::hooks::HookManager { + crate::hooks::HookManager::from_plugins_for_test(vec![DiscoveredPlugin { + name: "session-start".into(), + root: self.temp_dir.path().join("session-start"), + scope: PluginScope::Project, + }]) + } + + fn data_dir(&self) -> PathBuf { + self.temp_dir.path().join("data") + } + + fn hook_invocations(&self) -> usize { + std::fs::read_to_string(&self.hook_log) + .unwrap_or_default() + .lines() + .count() + } + } + struct CountingTextProvider { call_count: AtomicUsize, } @@ -3694,6 +3765,21 @@ exit 0 .collect() } + #[tokio::test] + async fn session_start_hook_emits_once_for_first_reply_turn() -> Result<()> { + let env = SessionStartHookTestEnv::new()?; + let provider = Arc::new(CountingTextProvider::new()); + let (agent, session_id) = + create_test_agent(env.data_dir(), env.hook_manager(), provider.clone()).await?; + + run_stop_hook_test_turn(&agent, &session_id, "first").await?; + run_stop_hook_test_turn(&agent, &session_id, "second").await?; + + assert_eq!(env.hook_invocations(), 1); + assert_eq!(provider.call_count(), 2); + Ok(()) + } + #[tokio::test] async fn stop_hook_block_cap_allows_configured_consecutive_blocks_then_overrides() -> Result<()> {