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

+25 -2
View File
@@ -3,7 +3,9 @@ use std::sync::Arc;
use async_trait::async_trait;
use codex_extension_api::ConfigContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::NoopExtensionEventSink;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadStartInput;
use codex_extension_api::TokenUsageContributor;
@@ -18,6 +20,7 @@ use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnAbortReason;
use crate::accounting::GoalAccountingState;
use crate::events::GoalEventEmitter;
use crate::tool::CreateGoalRequest;
use crate::tool::GoalToolExecutor;
@@ -35,6 +38,7 @@ impl GoalExtensionConfig {
#[derive(Clone)]
pub struct GoalExtension<C> {
backend: Arc<dyn GoalToolBackend>,
event_emitter: GoalEventEmitter,
goals_enabled: Arc<dyn Fn(&C) -> bool + Send + Sync>,
}
@@ -48,9 +52,18 @@ impl<C> GoalExtension<C> {
pub fn new(
backend: Arc<dyn GoalToolBackend>,
goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static,
) -> Self {
Self::new_with_event_sink(backend, Arc::new(NoopExtensionEventSink), goals_enabled)
}
pub fn new_with_event_sink(
backend: Arc<dyn GoalToolBackend>,
event_sink: Arc<dyn ExtensionEventSink>,
goals_enabled: impl Fn(&C) -> bool + Send + Sync + 'static,
) -> Self {
Self {
backend,
event_emitter: GoalEventEmitter::new(event_sink),
goals_enabled: Arc::new(goals_enabled),
}
}
@@ -231,14 +244,20 @@ where
return Vec::new();
};
vec![
Arc::new(GoalToolExecutor::get(thread_id, Arc::clone(&self.backend))),
Arc::new(GoalToolExecutor::get(
thread_id,
Arc::clone(&self.backend),
self.event_emitter.clone(),
)),
Arc::new(GoalToolExecutor::create(
thread_id,
Arc::clone(&self.backend),
self.event_emitter.clone(),
)),
Arc::new(GoalToolExecutor::update(
thread_id,
Arc::clone(&self.backend),
self.event_emitter.clone(),
)),
]
}
@@ -260,7 +279,11 @@ pub fn install_with_backend<C>(
) where
C: Send + Sync + 'static,
{
let extension = Arc::new(GoalExtension::new(backend, goals_enabled));
let extension = Arc::new(GoalExtension::new_with_event_sink(
backend,
registry.event_sink(),
goals_enabled,
));
registry.thread_lifecycle_contributor(extension.clone());
registry.config_contributor(extension.clone());
registry.turn_lifecycle_contributor(extension.clone());