mirror of
https://github.com/openai/codex.git
synced 2026-05-02 18:37:01 +00:00
start of hooks engine (#13276)
(Experimental) This PR adds a first MVP for hooks, with SessionStart and Stop The core design is: - hooks live in a dedicated engine under codex-rs/hooks - each hook type has its own event-specific file - hook execution is synchronous and blocks normal turn progression while running - matching hooks run in parallel, then their results are aggregated into a normalized HookRunSummary On the AppServer side, hooks are exposed as operational metadata rather than transcript-native items: - new live notifications: hook/started, hook/completed - persisted/replayed hook results live on Turn.hookRuns - we intentionally did not add hook-specific ThreadItem variants Hooks messages are not persisted, they remain ephemeral. The context changes they add are (they get appended to the user's prompt)
This commit is contained in:
@@ -9702,6 +9702,133 @@ async fn final_reasoning_then_message_without_deltas_are_rendered() {
|
||||
assert_snapshot!(combined);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deltas_then_same_final_message_are_rendered_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
// Stream some reasoning deltas first.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
|
||||
delta: "I will ".into(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
|
||||
delta: "first analyze the ".into(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent {
|
||||
delta: "request.".into(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::AgentReasoning(AgentReasoningEvent {
|
||||
text: "request.".into(),
|
||||
}),
|
||||
});
|
||||
|
||||
// Then stream answer deltas, followed by the exact same final message.
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "Here is the ".into(),
|
||||
}),
|
||||
});
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
|
||||
delta: "result.".into(),
|
||||
}),
|
||||
});
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "s1".into(),
|
||||
msg: EventMsg::AgentMessage(AgentMessageEvent {
|
||||
message: "Here is the result.".into(),
|
||||
phase: None,
|
||||
}),
|
||||
});
|
||||
|
||||
// Snapshot the combined visible content to ensure we render as expected
|
||||
// when deltas are followed by the identical final message.
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert_snapshot!(combined);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_events_render_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "hook-1".into(),
|
||||
msg: EventMsg::HookStarted(codex_protocol::protocol::HookStartedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::protocol::HookRunSummary {
|
||||
id: "session-start:0:/tmp/hooks.json".to_string(),
|
||||
event_name: codex_protocol::protocol::HookEventName::SessionStart,
|
||||
handler_type: codex_protocol::protocol::HookHandlerType::Command,
|
||||
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
|
||||
scope: codex_protocol::protocol::HookScope::Thread,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
status: codex_protocol::protocol::HookRunStatus::Running,
|
||||
status_message: Some("warming the shell".to_string()),
|
||||
started_at: 1,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
entries: vec![],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
chat.handle_codex_event(Event {
|
||||
id: "hook-1".into(),
|
||||
msg: EventMsg::HookCompleted(codex_protocol::protocol::HookCompletedEvent {
|
||||
turn_id: None,
|
||||
run: codex_protocol::protocol::HookRunSummary {
|
||||
id: "session-start:0:/tmp/hooks.json".to_string(),
|
||||
event_name: codex_protocol::protocol::HookEventName::SessionStart,
|
||||
handler_type: codex_protocol::protocol::HookHandlerType::Command,
|
||||
execution_mode: codex_protocol::protocol::HookExecutionMode::Sync,
|
||||
scope: codex_protocol::protocol::HookScope::Thread,
|
||||
source_path: PathBuf::from("/tmp/hooks.json"),
|
||||
display_order: 0,
|
||||
status: codex_protocol::protocol::HookRunStatus::Completed,
|
||||
status_message: Some("warming the shell".to_string()),
|
||||
started_at: 1,
|
||||
completed_at: Some(11),
|
||||
duration_ms: Some(10),
|
||||
entries: vec![
|
||||
codex_protocol::protocol::HookOutputEntry {
|
||||
kind: codex_protocol::protocol::HookOutputEntryKind::Warning,
|
||||
text: "Heads up from the hook".to_string(),
|
||||
},
|
||||
codex_protocol::protocol::HookOutputEntry {
|
||||
kind: codex_protocol::protocol::HookOutputEntryKind::Context,
|
||||
text: "Remember the startup checklist.".to_string(),
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
let combined = cells
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert_snapshot!("hook_events_render_snapshot", combined);
|
||||
}
|
||||
|
||||
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.
|
||||
// This renders the final visual as seen in a terminal: history above, then a blank line,
|
||||
// then the exec block, another blank line, the status line, a blank line, and the composer.
|
||||
|
||||
Reference in New Issue
Block a user