Emit goal update events from goal extension tools (#23306)

## Why

Goal creation and completion are moving through the goal extension, but
the rest of Codex still observes goal state through `ThreadGoalUpdated`
events. Without an event from the extension-owned tool path, a
model-initiated `create_goal` or `update_goal` can mutate the backend
and return a tool result while app-server and TUI listeners miss the
goal state transition.

## What changed

- Added `GoalEventEmitter` as a small wrapper around the host
`ExtensionEventSink` to build `EventMsg::ThreadGoalUpdated` events for
goal updates.
- Threaded the registry event sink into `GoalExtension` and the
`GoalToolExecutor`s created by the extension. The public
`GoalExtension::new` constructor keeps a `NoopExtensionEventSink`
fallback for standalone use.
- Emitted a goal update after successful `create_goal` and `update_goal`
tool calls. Until `ToolCall` exposes the current turn submission id,
these events use the tool call id as the event id and leave `turn_id`
unset.

Relevant code:

-
[`GoalEventEmitter::thread_goal_updated`](https://github.com/openai/codex/blob/1fe2d73890df9a50996f67f705d4da4cc3d4b866/codex-rs/ext/goal/src/events.rs#L19-L32)
- [`GoalToolExecutor` emission
points](https://github.com/openai/codex/blob/1fe2d73890df9a50996f67f705d4da4cc3d4b866/codex-rs/ext/goal/src/tool.rs#L161-L190)

## Testing

- `cargo test -p codex-goal-extension`
This commit is contained in:
jif-oai authored and GitHub committed 2026-05-18 16:14:37 +02:00
1 parent b631d92170
commit 4ca60ef9ff
4 files changed
+93 -5

No files matched your search

+33 -3
View File
@@ -15,6 +15,7 @@ use codex_protocol::protocol::validate_thread_goal_objective;
use serde::Deserialize;
use serde::Serialize;
use crate::events::GoalEventEmitter;
use crate::extension::GoalToolBackend;
use crate::spec::CREATE_GOAL_TOOL_NAME;
use crate::spec::GET_GOAL_TOOL_NAME;
@@ -28,6 +29,7 @@ pub(crate) struct GoalToolExecutor {
kind: GoalToolKind,
thread_id: ThreadId,
backend: Arc<dyn GoalToolBackend>,
event_emitter: GoalEventEmitter,
}
#[derive(Clone, Copy)]
@@ -65,27 +67,42 @@ enum CompletionBudgetReport {
}
impl GoalToolExecutor {
pub(crate) fn get(thread_id: ThreadId, backend: Arc<dyn GoalToolBackend>) -> Self {
pub(crate) fn get(
thread_id: ThreadId,
backend: Arc<dyn GoalToolBackend>,
event_emitter: GoalEventEmitter,
) -> Self {
Self {
kind: GoalToolKind::Get,
thread_id,
backend,
event_emitter,
}
}
pub(crate) fn create(thread_id: ThreadId, backend: Arc<dyn GoalToolBackend>) -> Self {
pub(crate) fn create(
thread_id: ThreadId,
backend: Arc<dyn GoalToolBackend>,
event_emitter: GoalEventEmitter,
) -> Self {
Self {
kind: GoalToolKind::Create,
thread_id,
backend,
event_emitter,
}
}
pub(crate) fn update(thread_id: ThreadId, backend: Arc<dyn GoalToolBackend>) -> Self {
pub(crate) fn update(
thread_id: ThreadId,
backend: Arc<dyn GoalToolBackend>,
event_emitter: GoalEventEmitter,
) -> Self {
Self {
kind: GoalToolKind::Update,
thread_id,
backend,
event_emitter,
}
}
}
@@ -146,6 +163,7 @@ impl GoalToolExecutor {
.create_goal(self.thread_id, request)
.await
.map_err(FunctionCallError::RespondToModel)?;
self.emit_goal_updated_from_tool_call(&invocation, goal.clone());
goal_response(Some(goal), CompletionBudgetReport::Omit)
}
@@ -168,8 +186,20 @@ impl GoalToolExecutor {
.complete_goal(self.thread_id)
.await
.map_err(FunctionCallError::RespondToModel)?;
self.emit_goal_updated_from_tool_call(&invocation, goal.clone());
goal_response(Some(goal), CompletionBudgetReport::Include)
}
fn emit_goal_updated_from_tool_call(&self, invocation: &ToolCall, goal: ThreadGoal) {
// TODO: ToolCall should expose the current turn submission id so goal
// tool events can set ThreadGoalUpdatedEvent.turn_id exactly as core
// does today. Until then, correlate the event with the tool call id.
self.event_emitter.thread_goal_updated(
invocation.call_id.clone(),
/*turn_id*/ None,
goal,
);
}
}
fn parse_arguments<T>(arguments: &str) -> Result<T, FunctionCallError>