[codex] Emit assistant reply text in Stop hooks (#9968)

This commit is contained in:
Max Novich
2026-06-24 10:03:01 -07:00
committed by GitHub
parent 2acae3b243
commit e51d70f1fa
3 changed files with 133 additions and 21 deletions
+110 -19
View File
@@ -68,6 +68,7 @@ use tracing::{debug, error, info, instrument, warn};
const DEFAULT_MAX_TURNS: u32 = 1000;
const DEFAULT_STOP_HOOK_BLOCK_CAP: u32 = 8;
const COMPACTION_THINKING_TEXT: &str = "goose is compacting the conversation...";
const MAX_TURNS_MESSAGE: &str = "I've reached the maximum number of actions I can do without user input. Would you like me to continue?";
const DEFAULT_FRONTEND_INSTRUCTIONS: &str = "The following tools are provided directly by the frontend and will be executed by the frontend when called.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -424,6 +425,39 @@ impl Agent {
.await;
}
fn stop_hook_context(
session_id: &str,
last_assistant_message: &str,
) -> crate::hooks::HookContext {
crate::hooks::HookContext::new(crate::hooks::HookEvent::Stop, session_id)
.with_last_assistant_message(last_assistant_message.to_string())
}
async fn emit_stop_hook(&self, session_id: &str, last_assistant_message: &str) {
if !self.hook_manager.has_hooks(crate::hooks::HookEvent::Stop) {
return;
}
self.hook_manager
.emit(
crate::hooks::HookEvent::Stop,
Self::stop_hook_context(session_id, last_assistant_message),
)
.await;
}
async fn emit_stop_hook_blocking(
&self,
session_id: &str,
last_assistant_message: &str,
) -> crate::hooks::HookDecision {
self.hook_manager
.emit_blocking(
crate::hooks::HookEvent::Stop,
Self::stop_hook_context(session_id, last_assistant_message),
)
.await
}
pub async fn steer(&self, session_id: &str, message: Message) {
self.pending_steers
.lock()
@@ -1883,18 +1917,14 @@ impl Agent {
guard.as_mut().and_then(|fot| fot.final_output.take())
};
if let Some(output) = final_output {
last_assistant_text = output.clone();
let message = Message::assistant().with_text(output);
yield AgentEvent::Message(message.clone());
session_manager.add_message(&session_config.id, &message).await?;
conversation.push(message);
let ctx = crate::hooks::HookContext::new(
crate::hooks::HookEvent::Stop,
&session_config.id,
);
match self
.hook_manager
.emit_blocking(crate::hooks::HookEvent::Stop, ctx)
.emit_stop_hook_blocking(&session_config.id, &last_assistant_text)
.await
{
crate::hooks::HookDecision::Allow => {
@@ -1926,11 +1956,8 @@ impl Agent {
turns_taken += 1;
}
if turns_taken > max_turns {
yield AgentEvent::Message(
Message::assistant().with_text(
"I've reached the maximum number of actions I can do without user input. Would you like me to continue?"
)
);
last_assistant_text = MAX_TURNS_MESSAGE.to_string();
yield AgentEvent::Message(Message::assistant().with_text(last_assistant_text.clone()));
break;
}
@@ -1951,6 +1978,7 @@ impl Agent {
&tools,
&toolshim_tools,
).await?;
last_assistant_text.clear();
let current_turn_tool_count = conversation.messages().iter()
.flat_map(|m| m.content.iter())
@@ -2038,7 +2066,7 @@ impl Agent {
if num_tool_requests == 0 {
let text = filtered_response.as_concat_text();
if !text.is_empty() {
last_assistant_text = text;
last_assistant_text.push_str(&text);
}
messages_to_add.push(response);
continue;
@@ -2549,6 +2577,7 @@ impl Agent {
}
if let Some(output) = pending_final_output.take() {
last_assistant_text = output.clone();
let message = Message::assistant().with_text(output);
messages_to_add.push(message.clone());
yield AgentEvent::Message(message);
@@ -2574,13 +2603,8 @@ impl Agent {
}
if exit_chat {
let ctx = crate::hooks::HookContext::new(
crate::hooks::HookEvent::Stop,
&session_config.id,
);
match self
.hook_manager
.emit_blocking(crate::hooks::HookEvent::Stop, ctx)
.emit_stop_hook_blocking(&session_config.id, &last_assistant_text)
.await
{
crate::hooks::HookDecision::Allow => {
@@ -2613,7 +2637,7 @@ impl Agent {
}
if !stop_hook_handled_for_exit {
self.emit_hook(crate::hooks::HookEvent::Stop, &session_config.id).await;
self.emit_stop_hook(&session_config.id, &last_assistant_text).await;
}
}.instrument(reply_stream_span));
Ok(inner)
@@ -3340,11 +3364,17 @@ if [ $((count % 2)) -eq 1 ]; then
exit 2
fi
exit 0
"#;
const RECORD_PAYLOAD_SCRIPT: &str = r#"#!/bin/sh
cat > "$PLUGIN_ROOT/payload.json"
exit 0
"#;
struct StopHookTestEnv {
temp_dir: TempDir,
hook_log: PathBuf,
payload_path: PathBuf,
}
impl StopHookTestEnv {
@@ -3372,6 +3402,7 @@ exit 0
Ok(Self {
temp_dir,
hook_log: plugin_dir.join("hook.log"),
payload_path: plugin_dir.join("payload.json"),
})
}
@@ -3393,6 +3424,11 @@ exit 0
.lines()
.count()
}
fn stop_payload(&self) -> Result<Value> {
let payload = std::fs::read_to_string(&self.payload_path)?;
Ok(serde_json::from_str(&payload)?)
}
}
struct CountingTextProvider {
@@ -3432,6 +3468,33 @@ exit 0
}
}
struct ChunkedTextProvider;
#[async_trait::async_trait]
impl crate::providers::base::Provider for ChunkedTextProvider {
async fn stream(
&self,
_model_config: &goose_providers::model::ModelConfig,
_session_id: &str,
_system_prompt: &str,
_messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let usage = ProviderUsage::new("mock-model".to_string(), Usage::default());
Ok(Box::pin(futures::stream::iter(vec![
Ok((Some(Message::assistant().with_text("streamed ")), None)),
Ok((
Some(Message::assistant().with_text("assistant reply")),
Some(usage),
)),
])))
}
fn get_name(&self) -> &str {
"chunked-text"
}
}
struct RefusingProvider {
call_count: AtomicUsize,
}
@@ -3649,6 +3712,34 @@ exit 0
Ok(())
}
#[tokio::test]
async fn stop_hook_payload_includes_streamed_assistant_reply_text() -> Result<()> {
let env = StopHookTestEnv::new(RECORD_PAYLOAD_SCRIPT)?;
let provider = Arc::new(ChunkedTextProvider);
let (agent, session_id) =
create_test_agent(env.data_dir(), env.hook_manager(), provider).await?;
let messages = run_stop_hook_test_turn(&agent, &session_id, "hello").await?;
let texts = visible_texts(&messages);
assert_eq!(texts.join(""), "streamed assistant reply");
let payload = env.stop_payload()?;
assert_eq!(payload.get("event").and_then(Value::as_str), Some("Stop"));
assert_eq!(
payload.get("session_id").and_then(Value::as_str),
Some(session_id.as_str())
);
assert_eq!(
payload
.get("last_assistant_message")
.and_then(Value::as_str),
Some("streamed assistant reply")
);
assert!(payload.get("message").is_none());
Ok(())
}
#[tokio::test]
async fn test_add_final_output_tool() -> Result<()> {
let agent = Agent::new();
+11
View File
@@ -166,6 +166,8 @@ pub struct HookContext {
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_assistant_message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub working_dir: Option<String>,
}
@@ -179,6 +181,7 @@ impl HookContext {
tool_input: None,
tool_output: None,
message: None,
last_assistant_message: None,
working_dir: None,
}
}
@@ -203,6 +206,14 @@ impl HookContext {
self
}
pub fn with_last_assistant_message(mut self, message: impl Into<String>) -> Self {
let message = message.into();
if !message.is_empty() {
self.last_assistant_message = Some(message);
}
self
}
pub fn with_working_dir(mut self, dir: impl Into<String>) -> Self {
self.working_dir = Some(dir.into());
self
@@ -133,7 +133,7 @@ Use `${PLUGIN_ROOT}` in a command to reference the plugin directory. goose also
|---|---|---|
| `SessionStart` | A session starts | None |
| `SessionEnd` | A session ends | None |
| `Stop` | goose receives a stop event | None |
| `Stop` | goose finishes a turn or receives a stop event | None |
| `UserPromptSubmit` | The user submits a prompt | Prompt text |
| `PreToolUse` | Before goose runs a tool | Tool name |
| `PostToolUse` | After a tool succeeds | Tool name |
@@ -151,7 +151,7 @@ The matcher is a regular expression matched against the most relevant string for
## Hook Payload
When a hook runs, goose writes a JSON payload to the command's stdin. The payload always includes the event name and session ID, and may include fields such as the tool name, tool input, user message, or working directory.
When a hook runs, goose writes a JSON payload to the command's stdin. The payload always includes the event name and session ID, and may include fields such as the tool name, tool input, user message, last assistant message, or working directory.
Example payload for a tool event:
@@ -166,6 +166,16 @@ Example payload for a tool event:
}
```
Example payload for a `Stop` event after an assistant reply:
```json
{
"event": "Stop",
"session_id": "abc-123",
"last_assistant_message": "Done. I updated the file and ran the tests."
}
```
Example script that reads the payload:
```bash