From a3ac5f521bac8f77b5a8c19ed16f11fc8d59fcf9 Mon Sep 17 00:00:00 2001 From: Reese Date: Wed, 6 May 2026 21:11:49 +0000 Subject: [PATCH] Add user prompt submit and stop project hooks to code-rs Extend code-rs project hooks with two new lifecycle events: - user.prompt_submit - stop This adds minimal upstream-inspired hook semantics for: - blocking a user prompt via exit code 2 + stderr - injecting prompt context into model input via stdout - continuing a turn from a stop hook via stderr - preventing recursive stop-hook continuation loops Also enrich hook payloads with session/turn metadata needed by adapter-backed integrations: - session_id - turn_id - transcript_path - model Includes focused regression coverage for fire/block/injection/stop/ loop-guard behavior, plus a debug-build TUI fix for background-event prelude insertion. --- code-rs/core/src/codex/exec.rs | 229 ++++++++++- code-rs/core/src/codex/streaming.rs | 188 ++++++--- code-rs/core/src/config_types.rs | 29 ++ code-rs/core/tests/tool_hooks.rs | 596 +++++++++++++++++++++++----- code-rs/tui/src/chatwidget.rs | 19 +- docs/config.md | 55 ++- scripts/code-hook-smoke.sh | 192 +++++++++ 7 files changed, 1141 insertions(+), 167 deletions(-) create mode 100755 scripts/code-hook-smoke.sh diff --git a/code-rs/core/src/codex/exec.rs b/code-rs/core/src/codex/exec.rs index 8cb044aa05c5..45ae9bfe820e 100644 --- a/code-rs/core/src/codex/exec.rs +++ b/code-rs/core/src/codex/exec.rs @@ -142,6 +142,43 @@ fn truncate_payload(text: &str, limit: usize) -> String { } } +fn trimmed_non_empty(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn join_text_chunks(chunks: Vec) -> Option { + if chunks.is_empty() { + None + } else { + Some(chunks.join("\n\n")) + } +} + +#[derive(Debug, Clone)] +pub(super) struct ProjectHookCommandResult { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, +} + +#[derive(Debug, Default, Clone)] +pub(super) struct UserPromptSubmitHookOutcome { + pub blocked: bool, + pub block_reason: Option, + pub additional_contexts: Vec, +} + +#[derive(Debug, Default, Clone)] +pub(super) struct StopHookOutcome { + pub blocked: bool, + pub continuation_prompt: Option, +} + fn build_exec_hook_payload( event: ProjectHookEvent, ctx: &ExecCommandContext, @@ -688,7 +725,16 @@ impl Session { let payload = build_exec_hook_payload(event, exec_ctx, params, output); for (idx, hook) in hooks.into_iter().enumerate() { self - .run_hook_command(turn_diff_tracker, &hook, event, &payload, Some(exec_ctx), attempt_req, idx) + .run_hook_command( + turn_diff_tracker, + &hook, + event, + &payload, + Some(exec_ctx), + None, + attempt_req, + idx, + ) .await; } } @@ -709,22 +755,159 @@ impl Session { let attempt_req = self.current_request_ordinal(); for (idx, hook) in hooks.into_iter().enumerate() { self - .run_hook_command(&mut tracker, &hook, event, &payload, None, attempt_req, idx) + .run_hook_command(&mut tracker, &hook, event, &payload, None, None, attempt_req, idx) + .await; + } + } + + pub(super) async fn run_user_prompt_submit_hooks( + &self, + sub_id: &str, + items: &[InputItem], + _final_output_json_schema: Option<&Value>, + attempt_req: u64, + ) -> UserPromptSubmitHookOutcome { + let transcript_path = self + .clone_rollout_recorder() + .map(|rec| rec.rollout_path.to_string_lossy().to_string()); + let prompt = items + .iter() + .filter_map(|item| match item { + InputItem::Text { text } => Some(text.trim()), + _ => None, + }) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n\n"); + let payload = json!({ + "event": ProjectHookEvent::UserPromptSubmit.as_str(), + "session_id": self.id, + "turn_id": sub_id, + "transcript_path": transcript_path, + "cwd": self.cwd.to_string_lossy(), + "model": self.client.get_model(), + "prompt": prompt, + }); + let results = self + .run_project_hooks_for_payload( + ProjectHookEvent::UserPromptSubmit, + &payload, + sub_id, + attempt_req, + ) + .await; + + let additional_contexts = results + .iter() + .filter_map(|result| trimmed_non_empty(&result.stdout)) + .collect::>(); + let block_reasons = results + .iter() + .filter(|result| result.exit_code == Some(2)) + .filter_map(|result| trimmed_non_empty(&result.stderr)) + .collect::>(); + let block_reason = join_text_chunks(block_reasons); + + UserPromptSubmitHookOutcome { + blocked: block_reason.is_some(), + block_reason, + additional_contexts, + } + } + + pub(super) async fn run_stop_hooks( + &self, + sub_id: &str, + last_assistant_message: Option<&str>, + stop_hook_active: bool, + attempt_req: u64, + ) -> StopHookOutcome { + let transcript_path = self + .clone_rollout_recorder() + .map(|rec| rec.rollout_path.to_string_lossy().to_string()); + let payload = json!({ + "event": ProjectHookEvent::Stop.as_str(), + "session_id": self.id, + "turn_id": sub_id, + "transcript_path": transcript_path, + "cwd": self.cwd.to_string_lossy(), + "model": self.client.get_model(), + "stop_hook_active": stop_hook_active, + "last_assistant_message": last_assistant_message, + }); + let results = self + .run_project_hooks_for_payload(ProjectHookEvent::Stop, &payload, sub_id, attempt_req) + .await; + let prompts = results + .into_iter() + .filter(|result| result.exit_code == Some(2)) + .filter_map(|result| trimmed_non_empty(&result.stderr)) + .collect::>(); + let continuation_prompt = join_text_chunks(prompts); + + StopHookOutcome { + blocked: continuation_prompt.is_some(), + continuation_prompt, + } + } + + async fn run_project_hooks_for_payload( + &self, + event: ProjectHookEvent, + payload: &Value, + sub_id: &str, + attempt_req: u64, + ) -> Vec { + if self.project_hooks.is_empty() { + return Vec::new(); + } + let hooks: Vec = self.project_hooks.hooks_for(event).cloned().collect(); + if hooks.is_empty() { + return Vec::new(); + } + let Some(_guard) = HookGuard::try_acquire(&self.hook_guard) else { + return Vec::new(); + }; + let mut tracker = TurnDiffTracker::new(); + let mut results = Vec::with_capacity(hooks.len()); + for (idx, hook) in hooks.into_iter().enumerate() { + let result = self + .run_hook_command( + &mut tracker, + &hook, + event, + payload, + None, + Some(sub_id), + attempt_req, + idx, + ) .await; + results.push(result); } + results } fn build_session_payload(&self, event: ProjectHookEvent) -> Value { + let transcript_path = self + .clone_rollout_recorder() + .map(|rec| rec.rollout_path.to_string_lossy().to_string()); match event { ProjectHookEvent::SessionStart => json!({ "event": event.as_str(), + "session_id": self.id, + "transcript_path": transcript_path, "cwd": self.cwd.to_string_lossy(), + "model": self.client.get_model(), "sandbox_policy": format!("{}", self.sandbox_policy), "approval_policy": format!("{}", self.approval_policy), }), ProjectHookEvent::SessionEnd => json!({ "event": event.as_str(), + "session_id": self.id, + "transcript_path": transcript_path, "cwd": self.cwd.to_string_lossy(), + "model": self.client.get_model(), "sandbox_policy": format!("{}", self.sandbox_policy), "approval_policy": format!("{}", self.approval_policy), }), @@ -739,11 +922,13 @@ impl Session { event: ProjectHookEvent, payload: &Value, base_ctx: Option<&ExecCommandContext>, + fallback_sub_id: Option<&str>, attempt_req: u64, index: usize, - ) { + ) -> ProjectHookCommandResult { let sub_id = base_ctx .map(|ctx| ctx.sub_id.clone()) + .or_else(|| fallback_sub_id.map(ToOwned::to_owned)) .unwrap_or_else(|| INITIAL_SUBMIT_ID.to_string()); let base_slug = base_ctx .map(|ctx| sanitize_identifier(&ctx.call_id)) @@ -798,7 +983,7 @@ impl Session { stdout_stream: None, }; - if let Err(err) = Box::pin(self.run_exec_with_events_inner( + match Box::pin(self.run_exec_with_events_inner( turn_diff_tracker, exec_ctx, exec_args, @@ -807,20 +992,28 @@ impl Session { attempt_req, false, )) - .await - { - let hook_label = hook - .name - .as_deref() - .unwrap_or_else(|| hook.command.first().map(String::as_str).unwrap_or("hook")); - let order = self.next_background_order(&sub_id, attempt_req, None); - self - .notify_background_event_with_order( - &sub_id, - order, - format!("Hook `{}` failed: {}", hook_label, get_error_message_ui(&err)), - ) - .await; + .await { + Ok(output) => ProjectHookCommandResult { + stdout: output.stdout.text, + stderr: output.stderr.text, + exit_code: Some(output.exit_code), + }, + Err(err) => { + let hook_label = hook + .name + .as_deref() + .unwrap_or_else(|| hook.command.first().map(String::as_str).unwrap_or("hook")); + let order = self.next_background_order(&sub_id, attempt_req, None); + let message = format!("Hook `{}` failed: {}", hook_label, get_error_message_ui(&err)); + self + .notify_background_event_with_order(&sub_id, order, message.clone()) + .await; + ProjectHookCommandResult { + stdout: String::new(), + stderr: message, + exit_code: None, + } + } } } diff --git a/code-rs/core/src/codex/streaming.rs b/code-rs/core/src/codex/streaming.rs index d5dd4ae86f79..7a45e32da6e8 100644 --- a/code-rs/core/src/codex/streaming.rs +++ b/code-rs/core/src/codex/streaming.rs @@ -2257,7 +2257,33 @@ async fn spawn_user_turn( items: Vec, final_output_json_schema: Option, origin: TaskOriginKind, -) { +) -> bool { + let attempt_req = sess.current_request_ordinal(); + let hook_outcome = sess + .run_user_prompt_submit_hooks( + &sub_id, + &items, + final_output_json_schema.as_ref(), + attempt_req, + ) + .await; + if !hook_outcome.additional_contexts.is_empty() { + record_project_hook_contexts(&sess, hook_outcome.additional_contexts).await; + } + if hook_outcome.blocked { + let order = sess.next_background_order(&sub_id, attempt_req, None); + let message = hook_outcome + .block_reason + .unwrap_or_else(|| "User prompt blocked by hook.".to_string()); + sess.notify_background_event_with_order( + &sub_id, + order, + format!("User prompt blocked by hook: {message}"), + ) + .await; + return false; + } + maybe_run_auto_context_compaction(&sess, &sub_id, &items).await; let turn_context = match final_output_json_schema { Some(schema) => sess.make_turn_context_with_schema(Some(schema)), @@ -2265,6 +2291,84 @@ async fn spawn_user_turn( }; let agent = AgentTask::spawn(Arc::clone(&sess), turn_context, sub_id, items, origin, true); sess.set_task(agent); + true +} + +async fn record_project_hook_contexts(sess: &Arc, additional_contexts: Vec) { + if additional_contexts.is_empty() { + return; + } + + let messages = additional_contexts + .into_iter() + .map(|text| ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { text }], + end_turn: None, + phase: None, + }) + .collect::>(); + sess.record_conversation_items(&messages).await; +} + +fn build_stop_continuation_item(prompt: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: prompt.to_string(), + }], + end_turn: None, + phase: None, + } +} + +async fn handle_follow_up_action(sess: Arc, action: FollowUpTurnAction) { + match action { + FollowUpTurnAction::PostTurnPendingInput => { + sess.start_internal_pending_only_turn( + POST_TURN_PENDING_ONLY_SENTINEL, + TaskOriginKind::PostTurn, + false, + ) + .await; + } + FollowUpTurnAction::ManualCompact(compact_sub_id) => { + let turn_context = sess.make_turn_context(); + let prompt_text = sess.compact_prompt_text(); + compact::spawn_compact_task( + Arc::clone(&sess), + turn_context, + compact_sub_id, + vec![InputItem::Text { text: prompt_text }], + ); + } + FollowUpTurnAction::PendingInput => { + sess.start_internal_pending_only_turn( + PENDING_ONLY_SENTINEL, + TaskOriginKind::PendingInput, + false, + ) + .await; + } + FollowUpTurnAction::QueuedUserInput(queued) => { + sess.cleanup_old_status_items().await; + let started = spawn_user_turn( + Arc::clone(&sess), + queued.submission_id, + queued.core_items, + None, + TaskOriginKind::QueuedUser, + ) + .await; + if !started + && let Some(next_action) = sess.take_follow_up_turn_action() + { + Box::pin(handle_follow_up_action(sess, next_action)).await; + } + } + } } fn context_window_for_model(model: &str) -> Option { @@ -2461,6 +2565,7 @@ async fn run_agent( } let mut last_task_message: Option = None; + let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Agent which contains // many turns, from the perspective of the user, it is a single turn. let mut turn_diff_tracker = TurnDiffTracker::new(); @@ -2819,6 +2924,40 @@ async fn run_agent( if let Some(m) = last_task_message.as_ref() { tracing::info!("core.turn completed: last_assistant_message.len={}", m.len()); } + if visible_to_user && !is_review_mode { + let stop_outcome = sess + .run_stop_hooks( + &sub_id, + last_task_message.as_deref(), + stop_hook_active, + sess.current_request_ordinal(), + ) + .await; + if stop_outcome.blocked { + if stop_hook_active { + let order = sess.next_background_order( + &sub_id, + sess.current_request_ordinal(), + None, + ); + sess + .notify_background_event_with_order( + &sub_id, + order, + "Stop hook requested another continuation while one was already active; ignoring to avoid a loop." + .to_string(), + ) + .await; + } else if let Some(prompt) = stop_outcome.continuation_prompt { + let continuation_item = build_stop_continuation_item(&prompt); + sess.record_conversation_items(std::slice::from_ref(&continuation_item)) + .await; + initial_response_item = Some(continuation_item); + stop_hook_active = true; + continue; + } + } + } sess.maybe_notify(UserNotification::AgentTurnComplete { turn_id: sub_id.clone(), input_messages: turn_input_messages, @@ -2884,52 +3023,7 @@ async fn run_agent( sess.tx_event.send(event).await.ok(); if let Some(action) = sess.take_follow_up_turn_action() { - match action { - FollowUpTurnAction::PostTurnPendingInput => { - sess.start_internal_pending_only_turn( - POST_TURN_PENDING_ONLY_SENTINEL, - TaskOriginKind::PostTurn, - false, - ) - .await; - } - FollowUpTurnAction::ManualCompact(compact_sub_id) => { - let turn_context = sess.make_turn_context(); - let prompt_text = sess.compact_prompt_text(); - compact::spawn_compact_task( - Arc::clone(&sess), - turn_context, - compact_sub_id, - vec![InputItem::Text { - text: prompt_text, - }], - ); - } - FollowUpTurnAction::PendingInput => { - sess.start_internal_pending_only_turn( - PENDING_ONLY_SENTINEL, - TaskOriginKind::PendingInput, - false, - ) - .await; - } - FollowUpTurnAction::QueuedUserInput(queued) => { - let sess_clone = Arc::clone(&sess); - tokio::spawn(async move { - sess_clone.cleanup_old_status_items().await; - let submission_id = queued.submission_id; - let items = queued.core_items; - spawn_user_turn( - sess_clone, - submission_id, - items, - None, - TaskOriginKind::QueuedUser, - ) - .await; - }); - } - } + handle_follow_up_action(Arc::clone(&sess), action).await; } } diff --git a/code-rs/core/src/config_types.rs b/code-rs/core/src/config_types.rs index ba36a845f10e..258cde1366a9 100644 --- a/code-rs/core/src/config_types.rs +++ b/code-rs/core/src/config_types.rs @@ -1568,6 +1568,8 @@ pub enum ProjectHookEvent { SessionStart, #[serde(rename = "session.end")] SessionEnd, + #[serde(rename = "user.prompt_submit", alias = "UserPromptSubmit")] + UserPromptSubmit, #[serde(rename = "tool.before")] ToolBefore, #[serde(rename = "tool.after")] @@ -1576,6 +1578,8 @@ pub enum ProjectHookEvent { FileBeforeWrite, #[serde(rename = "file.after_write")] FileAfterWrite, + #[serde(rename = "stop", alias = "Stop")] + Stop, } impl ProjectHookEvent { @@ -1583,10 +1587,12 @@ impl ProjectHookEvent { match self { ProjectHookEvent::SessionStart => "session.start", ProjectHookEvent::SessionEnd => "session.end", + ProjectHookEvent::UserPromptSubmit => "user.prompt_submit", ProjectHookEvent::ToolBefore => "tool.before", ProjectHookEvent::ToolAfter => "tool.after", ProjectHookEvent::FileBeforeWrite => "file.before_write", ProjectHookEvent::FileAfterWrite => "file.after_write", + ProjectHookEvent::Stop => "stop", } } @@ -1594,10 +1600,12 @@ impl ProjectHookEvent { match self { ProjectHookEvent::SessionStart => "session_start", ProjectHookEvent::SessionEnd => "session_end", + ProjectHookEvent::UserPromptSubmit => "user_prompt_submit", ProjectHookEvent::ToolBefore => "tool_before", ProjectHookEvent::ToolAfter => "tool_after", ProjectHookEvent::FileBeforeWrite => "file_before_write", ProjectHookEvent::FileAfterWrite => "file_after_write", + ProjectHookEvent::Stop => "stop", } } } @@ -1997,4 +2005,25 @@ mod tests { assert_eq!(cfg.phase_1_model.as_deref(), Some("phase1")); assert_eq!(cfg.phase_2_model.as_deref(), Some("phase2")); } + + #[test] + fn project_hook_event_deserializes_upstream_aliases() { + let user_prompt: ProjectHookConfig = toml::from_str( + r#" + event = "UserPromptSubmit" + run = ["echo", "hello"] + "#, + ) + .expect("should deserialize user prompt submit alias"); + let stop: ProjectHookConfig = toml::from_str( + r#" + event = "Stop" + run = ["echo", "hello"] + "#, + ) + .expect("should deserialize stop alias"); + + assert_eq!(user_prompt.event, ProjectHookEvent::UserPromptSubmit); + assert_eq!(stop.event, ProjectHookEvent::Stop); + } } diff --git a/code-rs/core/tests/tool_hooks.rs b/code-rs/core/tests/tool_hooks.rs index c9d88c9793c8..6181e6c11137 100644 --- a/code-rs/core/tests/tool_hooks.rs +++ b/code-rs/core/tests/tool_hooks.rs @@ -8,11 +8,13 @@ use code_core::built_in_model_providers; use code_core::config_types::{ProjectHookConfig, ProjectHookEvent}; use code_core::project_features::ProjectHooks; use code_core::protocol::{AskForApproval, EventMsg, InputItem, Op, SandboxPolicy}; -use code_core::{CodexAuth, ConversationManager, ModelProviderInfo}; +use code_core::{CodexAuth, CodexConversation, ConversationManager, ModelProviderInfo}; use serde_json::json; +use std::sync::Arc; use std::fs::{self, File}; use tempfile::TempDir; -use tokio::time::timeout; +use tokio::time::{timeout, Duration}; +use serial_test::serial; use wiremock::matchers::{method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -22,18 +24,127 @@ fn sse_response(body: String) -> ResponseTemplate { .set_body_string(body) } +fn assistant_message_event(text: &str) -> serde_json::Value { + json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "id": format!("msg-{text}"), + "role": "assistant", + "content": [{"type": "output_text", "text": text}], + } + }) +} + +fn response_completed_event(id: &str) -> serde_json::Value { + json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": 0, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": 0 + } + } + }) +} + +fn sse_body(events: &[serde_json::Value]) -> String { + events + .iter() + .map(|event| { + let event_type = event["type"].as_str().expect("event type"); + format!("event: {event_type}\ndata: {event}\n\n") + }) + .collect() +} + +fn hook_cmd(script: &str) -> Vec { + vec!["bash".to_string(), "-lc".to_string(), script.to_string()] +} + +fn configure_test( + code_home: &TempDir, + project_dir: &TempDir, + hook_configs: Vec, + server: &MockServer, +) -> code_core::config::Config { + let mut config = load_default_config_for_test(code_home); + config.cwd = project_dir.path().to_path_buf(); + config.approval_policy = AskForApproval::Never; + config.sandbox_policy = SandboxPolicy::DangerFullAccess; + config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); + config.model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers(None)["openai"].clone() + }; + config.model = "gpt-5.1-codex".to_string(); + config +} + +async fn new_conversation(config: code_core::config::Config) -> Arc { + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")) + .new_conversation(config) + .await + .expect("create conversation") + .conversation +} + +async fn submit_prompt(conversation: &Arc, text: &str) { + conversation + .submit(Op::UserInput { + items: vec![InputItem::Text { text: text.into() }], + final_output_json_schema: None, + }) + .await + .unwrap(); +} + +async fn collect_events_until_idle(conversation: &Arc) -> Vec { + let mut events = Vec::new(); + loop { + match timeout(Duration::from_millis(500), conversation.next_event()).await { + Ok(Ok(event)) => events.push(event.msg), + Ok(Err(err)) => panic!("unexpected error receiving event: {err:?}"), + Err(_) => break, + } + } + events +} + +async fn collect_events_until_task_complete(conversation: &Arc) -> Vec { + let mut events = Vec::new(); + let mut saw_task_complete = false; + for _ in 0..40 { + match timeout(Duration::from_secs(5), conversation.next_event()).await { + Ok(Ok(event)) => { + if matches!(event.msg, EventMsg::TaskComplete(_)) { + saw_task_complete = true; + } + events.push(event.msg); + if saw_task_complete { + break; + } + } + Ok(Err(err)) => panic!("unexpected error receiving event: {err:?}"), + Err(_) => break, + } + } + assert!(saw_task_complete, "did not receive TaskComplete event"); + events +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] async fn tool_hooks_fire_for_shell_exec() { let code_home = TempDir::new().unwrap(); let project_dir = TempDir::new().unwrap(); let log_path = project_dir.path().join("hooks.log"); File::create(&log_path).unwrap(); - let mut config = load_default_config_for_test(&code_home); - config.cwd = project_dir.path().to_path_buf(); - config.approval_policy = AskForApproval::Never; - config.sandbox_policy = SandboxPolicy::DangerFullAccess; - let hook_cmd = |label: &str| { vec![ "bash".to_string(), @@ -62,13 +173,12 @@ async fn tool_hooks_fire_for_shell_exec() { run_in_background: Some(false), }, ]; - config.project_hooks = ProjectHooks::from_configs(&hook_configs, &config.cwd); let server = MockServer::start().await; let function_call_args = json!({ "command": ["bash", "-lc", "echo exec-body"], - "workdir": config.cwd, + "workdir": project_dir.path(), "timeout_ms": null, "sandbox_permissions": null, "justification": null, @@ -83,55 +193,14 @@ async fn tool_hooks_fire_for_shell_exec() { "arguments": function_call_args.to_string(), } }); - let completed_one = json!({ - "type": "response.completed", - "response": { - "id": "resp-1", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - } - } - }); - - let body_one = format!( - "event: response.output_item.done\ndata: {}\n\n\ -event: response.completed\ndata: {}\n\n", + let body_one = sse_body(&[ function_call_item, - completed_one - ); - - let message_item = json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "id": "msg-1", - "role": "assistant", - "content": [{"type": "output_text", "text": "done"}], - } - }); - let completed_two = json!({ - "type": "response.completed", - "response": { - "id": "resp-2", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - } - } - }); - let body_two = format!( - "event: response.output_item.done\ndata: {}\n\n\ -event: response.completed\ndata: {}\n\n", - message_item, - completed_two - ); + response_completed_event("resp-1"), + ]); + let body_two = sse_body(&[ + assistant_message_event("done"), + response_completed_event("resp-2"), + ]); Mock::given(method("POST")) .and(path_regex(".*/responses$")) @@ -146,48 +215,11 @@ event: response.completed\ndata: {}\n\n", .mount(&server) .await; - config.model_provider = ModelProviderInfo { - base_url: Some(format!("{}/v1", server.uri())), - ..built_in_model_providers(None)["openai"].clone() - }; - config.model = "gpt-5.1-codex".to_string(); - - let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); - let codex = conversation_manager - .new_conversation(config) - .await - .expect("create conversation") - .conversation; - - codex - .submit(Op::UserInput { - items: vec![InputItem::Text { - text: "run hook".into(), - }], - final_output_json_schema: None, - }) - .await - .unwrap(); - - let mut events = Vec::new(); - let mut saw_task_complete = false; - for _ in 0..20 { - match timeout(std::time::Duration::from_secs(5), codex.next_event()).await { - Ok(Ok(event)) => { - if matches!(event.msg, EventMsg::TaskComplete(_)) { - saw_task_complete = true; - } - events.push(event.msg.clone()); - if saw_task_complete { - break; - } - } - Ok(Err(err)) => panic!("unexpected error receiving event: {err:?}"), - Err(_) => break, - } - } + let config = configure_test(&code_home, &project_dir, hook_configs, &server); + let conversation = new_conversation(config).await; - assert!(saw_task_complete, "did not receive TaskComplete event"); + submit_prompt(&conversation, "run hook").await; + let events = collect_events_until_task_complete(&conversation).await; let hook_before_seen = events.iter().any(|msg| match msg { EventMsg::ExecCommandBegin(ev) => ev.call_id.contains("_hook_tool_before"), @@ -200,7 +232,6 @@ event: response.completed\ndata: {}\n\n", let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2, "expected two model requests (tool + follow-up)"); - assert!(hook_before_seen, "tool.before hook did not emit ExecCommandBegin"); assert!(hook_after_seen, "tool.after hook did not emit ExecCommandEnd"); @@ -210,3 +241,370 @@ event: response.completed\ndata: {}\n\n", assert!(lines.iter().any(|l| l.contains("after:tool.after"))); assert!(lines.first().unwrap().contains("before")); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn user_prompt_submit_hook_fires_and_injects_context() { + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("prompt_hook.log"); + File::create(&log_path).unwrap(); + + let hook_configs = vec![ProjectHookConfig { + event: ProjectHookEvent::UserPromptSubmit, + name: Some("prompt".to_string()), + command: hook_cmd(&format!( + "echo 'Injected hook context'; echo prompt:${{CODE_HOOK_EVENT}} >> {}", + log_path.display() + )), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }]; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(sse_body(&[ + assistant_message_event("done"), + response_completed_event("resp-1"), + ]))) + .up_to_n_times(1) + .mount(&server) + .await; + + let config = configure_test(&code_home, &project_dir, hook_configs, &server); + let conversation = new_conversation(config).await; + + submit_prompt(&conversation, "hello world").await; + let events = collect_events_until_task_complete(&conversation).await; + + assert!(events.iter().any(|msg| match msg { + EventMsg::ExecCommandBegin(ev) => ev.call_id.contains("_hook_user_prompt_submit_1"), + _ => false, + })); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 1, "expected a single model request"); + let body = String::from_utf8_lossy(&requests[0].body); + assert!(body.contains("Injected hook context")); + assert!(body.contains("hello world")); + + let log_contents = fs::read_to_string(&log_path).unwrap(); + assert!(log_contents.contains("prompt:user.prompt_submit")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn blocked_user_prompt_submit_surfaces_and_skips_model_request() { + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("blocked_prompt_hook.log"); + File::create(&log_path).unwrap(); + + let hook_configs = vec![ProjectHookConfig { + event: ProjectHookEvent::UserPromptSubmit, + name: Some("prompt-block".to_string()), + command: hook_cmd(&format!( + "echo 'blocked by policy' >&2; echo prompt:${{CODE_HOOK_EVENT}} >> {}; exit 2", + log_path.display() + )), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }]; + + let server = MockServer::start().await; + let config = configure_test(&code_home, &project_dir, hook_configs, &server); + let conversation = new_conversation(config).await; + + submit_prompt(&conversation, "hello world").await; + let events = collect_events_until_idle(&conversation).await; + + assert!(events.iter().any(|msg| match msg { + EventMsg::ExecCommandEnd(ev) => { + ev.call_id.contains("_hook_user_prompt_submit_1") + && ev.stderr.contains("blocked by policy") + && ev.exit_code == 2 + } + EventMsg::BackgroundEvent(ev) => ev.message.contains("User prompt blocked by hook"), + _ => false, + })); + assert!( + !events.iter().any(|msg| matches!(msg, EventMsg::TaskStarted | EventMsg::TaskComplete(_))), + "blocked prompt should not start a task" + ); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 0, "blocked prompt should not reach the model"); + + let log_contents = fs::read_to_string(&log_path).unwrap(); + assert!(log_contents.contains("prompt:user.prompt_submit")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn user_prompt_submit_payload_omits_multimodal_items() { + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let payload_path = project_dir.path().join("prompt_payload.json"); + File::create(&payload_path).unwrap(); + let huge_data_url = format!("data:image/png;base64,{}", "A".repeat(20_000)); + + let hook_configs = vec![ProjectHookConfig { + event: ProjectHookEvent::UserPromptSubmit, + name: Some("prompt-payload".to_string()), + command: hook_cmd(&format!( + "printf %s \"$CODE_HOOK_PAYLOAD\" > {}", + payload_path.display() + )), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }]; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(sse_body(&[ + assistant_message_event("done"), + response_completed_event("resp-1"), + ]))) + .up_to_n_times(1) + .mount(&server) + .await; + + let config = configure_test(&code_home, &project_dir, hook_configs, &server); + let conversation = new_conversation(config).await; + + conversation + .submit(Op::UserInput { + items: vec![ + InputItem::Text { + text: "describe image".into(), + }, + InputItem::Image { + image_url: huge_data_url.clone(), + }, + ], + final_output_json_schema: None, + }) + .await + .unwrap(); + collect_events_until_task_complete(&conversation).await; + + let payload = fs::read_to_string(&payload_path).unwrap(); + assert!(payload.contains("describe image")); + assert!(!payload.contains("\"items\"")); + assert!(!payload.contains(&huge_data_url)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn stop_hook_fires_and_joins_continuation_prompts() { + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("stop_hook.log"); + File::create(&log_path).unwrap(); + + let hook_configs = vec![ + ProjectHookConfig { + event: ProjectHookEvent::Stop, + name: Some("stop-a".to_string()), + command: hook_cmd(&format!( + "echo 'retry with tests' >&2; echo stop-a:${{CODE_HOOK_EVENT}} >> {}; exit 2", + log_path.display() + )), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }, + ProjectHookConfig { + event: ProjectHookEvent::Stop, + name: Some("stop-b".to_string()), + command: hook_cmd(&format!( + "echo 'also mention lint' >&2; echo stop-b:${{CODE_HOOK_EVENT}} >> {}; exit 2", + log_path.display() + )), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }, + ]; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(sse_body(&[ + assistant_message_event("first pass complete"), + response_completed_event("resp-1"), + ]))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(sse_body(&[ + assistant_message_event("second pass complete"), + response_completed_event("resp-2"), + ]))) + .up_to_n_times(1) + .mount(&server) + .await; + + let config = configure_test(&code_home, &project_dir, hook_configs, &server); + let conversation = new_conversation(config).await; + + submit_prompt(&conversation, "finish this task").await; + collect_events_until_task_complete(&conversation).await; + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2, "stop hook should trigger a continuation turn"); + let second_body = String::from_utf8_lossy(&requests[1].body); + assert!(second_body.contains("retry with tests")); + assert!(second_body.contains("also mention lint")); + assert_eq!( + second_body.matches("retry with tests").count(), + 1, + "stop continuation prompt should only appear once in the follow-up turn" + ); + assert_eq!( + second_body.matches("also mention lint").count(), + 1, + "joined stop continuation prompt should only appear once in the follow-up turn" + ); + + let log_contents = fs::read_to_string(&log_path).unwrap(); + assert!(log_contents.contains("stop-a:stop")); + assert!(log_contents.contains("stop-b:stop")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn stop_hook_loop_guard_ignores_second_continuation_request() { + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("stop_guard_hook.log"); + File::create(&log_path).unwrap(); + + let hook_configs = vec![ProjectHookConfig { + event: ProjectHookEvent::Stop, + name: Some("stop-loop".to_string()), + command: hook_cmd(&format!( + "echo 'retry forever' >&2; echo stop:${{CODE_HOOK_EVENT}} >> {}; exit 2", + log_path.display() + )), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }]; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(sse_body(&[ + assistant_message_event("first pass complete"), + response_completed_event("resp-1"), + ]))) + .up_to_n_times(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(sse_body(&[ + assistant_message_event("second pass complete"), + response_completed_event("resp-2"), + ]))) + .up_to_n_times(1) + .mount(&server) + .await; + + let config = configure_test(&code_home, &project_dir, hook_configs, &server); + let conversation = new_conversation(config).await; + + submit_prompt(&conversation, "finish this task").await; + let events = collect_events_until_task_complete(&conversation).await; + + assert!(events.iter().any(|msg| match msg { + EventMsg::BackgroundEvent(ev) => ev.message.contains("Stop hook requested another continuation"), + _ => false, + })); + + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), 2, "loop guard should cap stop continuations at one extra turn"); + let second_body = String::from_utf8_lossy(&requests[1].body); + assert_eq!( + second_body.matches("retry forever").count(), + 1, + "loop-guarded continuation prompt should only appear once in the follow-up turn" + ); + + let log_contents = fs::read_to_string(&log_path).unwrap(); + assert_eq!(log_contents.lines().count(), 2, "stop hook should fire for both completions"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[serial] +async fn session_start_and_stop_hooks_include_session_metadata() { + let code_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + let log_path = project_dir.path().join("session_stop_payloads.log"); + File::create(&log_path).unwrap(); + + let session_cmd = format!( + "python3 - <<'PY'\nimport json, os\np=json.loads(os.environ['CODE_HOOK_PAYLOAD'])\nwith open({:?}, 'a', encoding='utf-8') as f:\n f.write('session_start|{{}}|{{}}|{{}}\\n'.format(bool(p.get('session_id')), bool(p.get('transcript_path')), bool(p.get('model'))))\nPY", + log_path + ); + let stop_cmd = format!( + "python3 - <<'PY'\nimport json, os\np=json.loads(os.environ['CODE_HOOK_PAYLOAD'])\nwith open({:?}, 'a', encoding='utf-8') as f:\n f.write('stop|{{}}|{{}}|{{}}|{{}}\\n'.format(bool(p.get('session_id')), bool(p.get('transcript_path')), bool(p.get('model')), bool(p.get('turn_id'))))\nPY", + log_path + ); + + let hook_configs = vec![ + ProjectHookConfig { + event: ProjectHookEvent::SessionStart, + name: Some("session-start".to_string()), + command: hook_cmd(&session_cmd), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }, + ProjectHookConfig { + event: ProjectHookEvent::Stop, + name: Some("stop-meta".to_string()), + command: hook_cmd(&stop_cmd), + cwd: None, + env: None, + timeout_ms: None, + run_in_background: Some(false), + }, + ]; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path_regex(".*/responses$")) + .respond_with(sse_response(sse_body(&[ + assistant_message_event("done"), + response_completed_event("resp-1"), + ]))) + .up_to_n_times(1) + .mount(&server) + .await; + + let config = configure_test(&code_home, &project_dir, hook_configs, &server); + let conversation = new_conversation(config).await; + + submit_prompt(&conversation, "hello world").await; + collect_events_until_task_complete(&conversation).await; + + let log_contents = fs::read_to_string(&log_path).unwrap(); + assert!(log_contents.contains("session_start|True|True|True")); + assert!(log_contents.contains("stop|True|True|True|True")); +} diff --git a/code-rs/tui/src/chatwidget.rs b/code-rs/tui/src/chatwidget.rs index 1a52c53d535f..60f72ecf97c7 100644 --- a/code-rs/tui/src/chatwidget.rs +++ b/code-rs/tui/src/chatwidget.rs @@ -11190,8 +11190,23 @@ impl ChatWidget<'_> { /// Push a cell using a synthetic key at the TOP of the NEXT request. fn history_push_top_next_req(&mut self, cell: impl HistoryCell + 'static) { - let key = self.next_req_key_top(); - let _ = self.history_insert_with_key_global_tagged(Box::new(cell), key, "prelude", None); + if cell.kind() == HistoryCellType::BackgroundEvent { + let key = self.system_order_key(SystemPlacement::PrePromptInCurrent, None); + let _ = self.history_insert_with_key_global_tagged( + Box::new(cell), + key, + "background", + None, + ); + } else { + let key = self.next_req_key_top(); + let _ = self.history_insert_with_key_global_tagged( + Box::new(cell), + key, + "prelude", + None, + ); + } } fn history_replace_with_record( &mut self, diff --git a/docs/config.md b/docs/config.md index 6ce1e707dc18..f89188865f73 100644 --- a/docs/config.md +++ b/docs/config.md @@ -940,18 +940,42 @@ timeout_ms = 60000 [[projects."/Users/me/src/my-app".hooks]] event = "tool.after" run = "npm run lint -- --changed" + +[[projects."/Users/me/src/my-app".hooks]] +event = "user.prompt_submit" +run = "./scripts/review-prompt.sh" + +[[projects."/Users/me/src/my-app".hooks]] +event = "stop" +run = "./scripts/continue-before-finish.sh" ``` Supported hook events: - `session.start`: after the session is configured (once per launch) - `session.end`: before shutdown completes +- `user.prompt_submit`: before a user message is turned into model input - `tool.before`: immediately before each exec/tool command runs - `tool.after`: once an exec/tool command finishes (regardless of exit code) - `file.before_write`: right before an `apply_patch` is applied - `file.after_write`: after an `apply_patch` completes and diffs are emitted +- `stop`: after the assistant has produced a terminal response for the turn, but before the turn is finalized + +For compatibility with upstream Codex hook names, `event = "UserPromptSubmit"` and `event = "Stop"` are accepted as aliases. The documented `code-rs` names remain `user.prompt_submit` and `stop`. + +Hook commands run inside the same sandbox mode as the session and appear in the TUI as their own exec cells. Failures are surfaced as background events and, for ordinary lifecycle hooks, do not block the main task. Each invocation receives environment variables such as `CODE_HOOK_EVENT`, `CODE_HOOK_NAME`, `CODE_HOOK_INDEX`, `CODE_HOOK_CALL_ID`, `CODE_HOOK_PAYLOAD` (JSON describing the context), `CODE_SESSION_CWD`, and—when applicable—`CODE_HOOK_SOURCE_CALL_ID`. Hooks may also set `cwd`, provide additional `env` entries, and specify `timeout_ms`. -Hook commands run inside the same sandbox mode as the session and appear in the TUI as their own exec cells. Failures are surfaced as background events but do not block the main task. Each invocation receives environment variables such as `CODE_HOOK_EVENT`, `CODE_HOOK_NAME`, `CODE_HOOK_INDEX`, `CODE_HOOK_CALL_ID`, `CODE_HOOK_PAYLOAD` (JSON describing the context), `CODE_SESSION_CWD`, and—when applicable—`CODE_HOOK_SOURCE_CALL_ID`. Hooks may also set `cwd`, provide additional `env` entries, and specify `timeout_ms`. +`user.prompt_submit` and `stop` have minimal upstream-like control semantics: + +- Exit `0`: continue normally. +- Exit `2` with a non-empty stderr: block. + - For `user.prompt_submit`, the user message is not sent to the model and the block reason is surfaced in the hook's exec cell. + - For `stop`, the stderr text becomes a continuation prompt. If multiple hooks block, their prompts are joined with a blank line and sent back to the model as a follow-up user message. +- Non-empty stdout from a `user.prompt_submit` hook is injected into conversation history as developer context before the model request is built. This happens even if the prompt is blocked, matching the upstream spirit of preserving hook-supplied context for later turns. + +The `stop` hook includes `stop_hook_active` in its payload. Code allows at most one stop-driven continuation per turn; if a second stop continuation is requested while one is already active, the request is ignored and a background warning is emitted. + +In the TUI, the primary surface for a blocked `user.prompt_submit` hook is the hook's own exec cell, which shows the hook exit code and stderr text. Code may also emit a background summary when it can do so without interfering with turn startup. Example `tool.after` payload: @@ -969,6 +993,35 @@ Example `tool.after` payload: } ``` +Example `user.prompt_submit` payload: + +```json +{ + "event": "user.prompt_submit", + "session_id": "0195f3f5-0dcb-7f5c-8dc5-7b4b24f8f0aa", + "turn_id": "42", + "transcript_path": "/Users/me/.code/sessions/rollout-2026-05-06T12-00-00-0195f3f5-0dcb-7f5c-8dc5-7b4b24f8f0aa.jsonl", + "cwd": "/Users/me/src/my-app", + "model": "gpt-5.1-codex", + "prompt": "please finish this task" +} +``` + +Example `stop` payload: + +```json +{ + "event": "stop", + "session_id": "0195f3f5-0dcb-7f5c-8dc5-7b4b24f8f0aa", + "turn_id": "42", + "transcript_path": "/Users/me/.code/sessions/rollout-2026-05-06T12-00-00-0195f3f5-0dcb-7f5c-8dc5-7b4b24f8f0aa.jsonl", + "cwd": "/Users/me/src/my-app", + "model": "gpt-5.1-codex", + "stop_hook_active": false, + "last_assistant_message": "Implementation is complete and tests are green." +} +``` + ## Project Commands Define project-scoped commands under `[[projects."".commands]]`. Each command needs a unique `name` and either an array (`command`) or string (`run`) describing how to invoke it. Optional fields include `description`, `cwd`, `env`, and `timeout_ms`. diff --git a/scripts/code-hook-smoke.sh b/scripts/code-hook-smoke.sh new file mode 100755 index 000000000000..8f4c3495e4eb --- /dev/null +++ b/scripts/code-hook-smoke.sh @@ -0,0 +1,192 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)" +REPO_ROOT="$(cd -- "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)" + +BIN="${CODE_HOOK_SMOKE_BIN:-${REPO_ROOT}/code-rs/target/debug/code}" +PROMPT_TEXT="${CODE_HOOK_SMOKE_PROMPT:-finish the task}" +KEEP_ROOT="${CODE_HOOK_SMOKE_KEEP_ROOT:-0}" + +if [[ ! -x "${BIN}" ]]; then + echo "[code-hook-smoke] missing CLI binary: ${BIN}" >&2 + echo "[code-hook-smoke] build it first with: (cd code-rs && cargo build --bin code --bin code-tui --bin code-exec)" >&2 + exit 1 +fi + +ROOT="$(mktemp -d /tmp/code-hook-smoke.XXXXXX)" +SMOKE_HOME="${ROOT}/home" +PROJECT="${ROOT}/project" +HOOK_LOG="${ROOT}/hooks.log" +REQ_LOG="${ROOT}/requests.log" +CLI_OUT="${ROOT}/cli.out" +CLI_ERR="${ROOT}/cli.err" +PORT_FILE="${ROOT}/port" + +cleanup() { + if [[ -n "${SERVER_PID:-}" ]]; then + kill "${SERVER_PID}" >/dev/null 2>&1 || true + wait "${SERVER_PID}" 2>/dev/null || true + fi + if [[ "${KEEP_ROOT}" != "1" ]]; then + rm -rf "${ROOT}" + fi +} +trap cleanup EXIT + +mkdir -p "${SMOKE_HOME}" "${PROJECT}" +git -C "${PROJECT}" init -q + +cat > "${SMOKE_HOME}/config.toml" <> '${HOOK_LOG}'"] + +[[projects."${PROJECT}".hooks]] +event = "stop" +run = ["bash", "-c", "if printf '%s' \"\$CODE_HOOK_PAYLOAD\" | grep -q '\"stop_hook_active\":true'; then echo stop:\$CODE_HOOK_EVENT >> '${HOOK_LOG}'; exit 0; fi; echo 'continue via smoke stop' >&2; echo stop:\$CODE_HOOK_EVENT >> '${HOOK_LOG}'; exit 2"] +EOF + +python3 - "${PORT_FILE}" "${REQ_LOG}" <<'PY' & +import http.server +import json +import socketserver +import sys + +port_file, req_log = sys.argv[1:3] +state = {"count": 0} + + +class Handler(http.server.BaseHTTPRequestHandler): + def log_message(self, *args): + return + + def do_POST(self): + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length) + state["count"] += 1 + with open(req_log, "ab") as fh: + fh.write(b"===REQUEST===\n") + fh.write(body) + fh.write(b"\n") + + idx = state["count"] + text = "draft complete" if idx == 1 else "all set" + events = [ + { + "type": "response.output_item.done", + "item": { + "type": "message", + "id": f"msg-{idx}", + "role": "assistant", + "content": [{"type": "output_text", "text": text}], + }, + }, + { + "type": "response.completed", + "response": { + "id": f"resp-{idx}", + "usage": { + "input_tokens": 0, + "input_tokens_details": None, + "output_tokens": 0, + "output_tokens_details": None, + "total_tokens": 0, + }, + }, + }, + ] + + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.end_headers() + for event in events: + payload = json.dumps(event) + self.wfile.write(f"event: {event['type']}\ndata: {payload}\n\n".encode()) + self.wfile.flush() + + +class Server(socketserver.ThreadingMixIn, http.server.HTTPServer): + daemon_threads = True + + +server = Server(("127.0.0.1", 0), Handler) +with open(port_file, "w", encoding="utf-8") as fh: + fh.write(str(server.server_port)) +server.serve_forever() +PY +SERVER_PID=$! + +for _ in $(seq 1 50); do + if [[ -s "${PORT_FILE}" ]]; then + break + fi + sleep 0.1 +done +PORT="$(cat "${PORT_FILE}")" + +set +e +( + cd "${PROJECT}" + CODE_HOME="${SMOKE_HOME}" \ + CODEX_HOME="${SMOKE_HOME}" \ + CODEX_API_KEY=test \ + OPENAI_BASE_URL="http://127.0.0.1:${PORT}/v1" \ + "${BIN}" exec --skip-git-repo-check --sandbox danger-full-access "${PROMPT_TEXT}" +) >"${CLI_OUT}" 2>"${CLI_ERR}" +STATUS=$? +set -e + +REQUEST_COUNT="$(grep -c '^===REQUEST===' "${REQ_LOG}")" +INJECTED_CONTEXT_COUNT="$(grep -o 'Injected smoke context' "${REQ_LOG}" | wc -l | tr -d ' ')" +STOP_PROMPT_COUNT="$(grep -o 'continue via smoke stop' "${REQ_LOG}" | wc -l | tr -d ' ')" + +echo "SMOKE_ROOT=${ROOT}" +echo "SMOKE_EXIT=${STATUS}" +echo "REQUEST_COUNT=${REQUEST_COUNT}" +echo "HOOK_LOG_EXISTS=$([ -f "${HOOK_LOG}" ] && echo yes || echo no)" +echo "INJECTED_CONTEXT_PRESENT=$(grep -q 'Injected smoke context' "${REQ_LOG}" && echo yes || echo no)" +echo "STOP_PROMPT_PRESENT=$(grep -q 'continue via smoke stop' "${REQ_LOG}" && echo yes || echo no)" +echo "INJECTED_CONTEXT_COUNT=${INJECTED_CONTEXT_COUNT}" +echo "STOP_PROMPT_COUNT=${STOP_PROMPT_COUNT}" +echo "--- HOOK LOG ---" +cat "${HOOK_LOG}" +echo "--- REQUEST LOG SNIPPET ---" +sed -n '1,80p' "${REQ_LOG}" +echo "--- CLI STDOUT ---" +cat "${CLI_OUT}" +echo "--- CLI STDERR ---" +cat "${CLI_ERR}" + +if [[ "${STATUS}" -ne 0 ]]; then + exit "${STATUS}" +fi +if [[ ! -f "${HOOK_LOG}" ]]; then + echo "missing hook log" >&2 + exit 1 +fi +if ! grep -q 'prompt:user.prompt_submit' "${HOOK_LOG}"; then + echo "user.prompt_submit did not fire" >&2 + exit 1 +fi +if [[ "$(grep -c '^stop:stop$' "${HOOK_LOG}")" -lt 2 ]]; then + echo "stop hook did not fire twice" >&2 + exit 1 +fi +if ! grep -q 'Injected smoke context' "${REQ_LOG}"; then + echo "injected context missing from model input" >&2 + exit 1 +fi +if ! grep -q 'continue via smoke stop' "${REQ_LOG}"; then + echo "stop continuation prompt missing from model input" >&2 + exit 1 +fi +if [[ "${STOP_PROMPT_COUNT}" -ne 1 ]]; then + echo "stop continuation prompt should appear exactly once in model input" >&2 + exit 1 +fi