mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
ecb41fcb64
## Why Add a standalone image generation path that can be exercised independently of hosted Responses image generation, while retaining the hosted tool as fallback unless the extension is actually available to the model. ## What changed - Added the `codex-image-generation-extension` crate with standalone generate/edit execution, prior-image selection for edits, model-visible image output, and local generated-image persistence. - Installed the extension in app-server behind the disabled-by-default `imagegenext` feature and backend eligibility checks. - Updated core tool planning so eligible `image_gen.imagegen` exposure replaces hosted `image_generation`, while unavailable configurations retain hosted fallback. - Added coverage for extension behavior, edit history reuse, feature gating, auth eligibility, and hosted-tool replacement. - The extension is installed through app-server only in this PR; other execution paths retain hosted image generation because hosted replacement occurs only when the standalone executor is actually registered and model-visible. - The initial extension contract intentionally fixes the image model to `gpt-image-2` and uses automatic image parameters. - Native generated-image history/card parity and rollout persistence cleanup are intentionally deferred follow-up work. ## Validation - `just test -p codex-image-generation-extension` - `just test -p codex-features` - `just test -p codex-core hosted_tools_follow_provider_auth_model_and_config_gates` - `just test -p codex-app-server` - `just fix -p codex-image-generation-extension -p codex-features -p codex-core -p codex-app-server` - `just fmt` - `just bazel-lock-update` - `just bazel-lock-check` --------- Co-authored-by: jif-oai <jif@openai.com>
167 lines
6.0 KiB
Rust
167 lines
6.0 KiB
Rust
use std::sync::Arc;
|
|
use std::sync::Weak;
|
|
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
|
|
use codex_core::NewThread;
|
|
use codex_core::StartThreadOptions;
|
|
use codex_core::ThreadManager;
|
|
use codex_core::config::Config;
|
|
use codex_extension_api::AgentSpawnFuture;
|
|
use codex_extension_api::AgentSpawner;
|
|
use codex_extension_api::ExtensionEventSink;
|
|
use codex_extension_api::ExtensionRegistry;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_login::AuthManager;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::protocol::Event;
|
|
use codex_protocol::protocol::EventMsg;
|
|
|
|
use crate::outgoing_message::OutgoingMessageSender;
|
|
|
|
pub(crate) fn thread_extensions<S>(
|
|
guardian_agent_spawner: S,
|
|
event_sink: Arc<dyn ExtensionEventSink>,
|
|
auth_manager: Arc<AuthManager>,
|
|
) -> Arc<ExtensionRegistry<Config>>
|
|
where
|
|
S: AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> + 'static,
|
|
{
|
|
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
|
|
codex_guardian::install(&mut builder, guardian_agent_spawner);
|
|
codex_memories_extension::install(&mut builder, codex_otel::global());
|
|
codex_web_search_extension::install(&mut builder, auth_manager.clone());
|
|
codex_image_generation_extension::install(&mut builder, auth_manager);
|
|
Arc::new(builder.build())
|
|
}
|
|
|
|
pub(crate) fn app_server_extension_event_sink(
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
) -> Arc<dyn ExtensionEventSink> {
|
|
Arc::new(AppServerExtensionEventSink { outgoing })
|
|
}
|
|
|
|
struct AppServerExtensionEventSink {
|
|
outgoing: Arc<OutgoingMessageSender>,
|
|
}
|
|
|
|
impl ExtensionEventSink for AppServerExtensionEventSink {
|
|
fn emit(&self, event: Event) {
|
|
match event.msg {
|
|
EventMsg::ThreadGoalUpdated(thread_goal_event) => {
|
|
self.outgoing
|
|
.try_send_server_notification(ServerNotification::ThreadGoalUpdated(
|
|
ThreadGoalUpdatedNotification {
|
|
thread_id: thread_goal_event.thread_id.to_string(),
|
|
turn_id: thread_goal_event.turn_id,
|
|
goal: thread_goal_event.goal.into(),
|
|
},
|
|
));
|
|
}
|
|
msg => {
|
|
tracing::debug!(event_id = %event.id, ?msg, "dropping unsupported extension event");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn guardian_agent_spawner(
|
|
thread_manager: Weak<ThreadManager>,
|
|
) -> impl AgentSpawner<StartThreadOptions, Spawned = NewThread, Error = CodexErr> {
|
|
move |forked_from_thread_id: ThreadId,
|
|
options: StartThreadOptions|
|
|
-> AgentSpawnFuture<'static, NewThread, CodexErr> {
|
|
let thread_manager = thread_manager.clone();
|
|
Box::pin(async move {
|
|
let thread_manager = thread_manager.upgrade().ok_or_else(|| {
|
|
CodexErr::UnsupportedOperation("thread manager dropped".to_string())
|
|
})?;
|
|
thread_manager
|
|
.spawn_subagent(forked_from_thread_id, options)
|
|
.await
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::time::Duration;
|
|
|
|
use codex_analytics::AnalyticsEventsClient;
|
|
use codex_app_server_protocol::ServerNotification;
|
|
use codex_app_server_protocol::ThreadGoal as AppServerThreadGoal;
|
|
use codex_app_server_protocol::ThreadGoalStatus as AppServerThreadGoalStatus;
|
|
use codex_protocol::protocol::ThreadGoal;
|
|
use codex_protocol::protocol::ThreadGoalStatus;
|
|
use codex_protocol::protocol::ThreadGoalUpdatedEvent;
|
|
use pretty_assertions::assert_eq;
|
|
use tokio::sync::mpsc;
|
|
use tokio::time::timeout;
|
|
|
|
use super::*;
|
|
use crate::outgoing_message::OutgoingEnvelope;
|
|
use crate::outgoing_message::OutgoingMessage;
|
|
|
|
#[tokio::test]
|
|
async fn app_server_event_sink_forwards_thread_goal_updates() {
|
|
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(4);
|
|
let outgoing = Arc::new(OutgoingMessageSender::new(
|
|
outgoing_tx,
|
|
AnalyticsEventsClient::disabled(),
|
|
));
|
|
let sink = app_server_extension_event_sink(outgoing);
|
|
let thread_id = ThreadId::default();
|
|
|
|
sink.emit(Event {
|
|
id: "call-1".to_string(),
|
|
msg: EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent {
|
|
thread_id,
|
|
turn_id: Some("turn-1".to_string()),
|
|
goal: ThreadGoal {
|
|
thread_id,
|
|
objective: "wire extension events".to_string(),
|
|
status: ThreadGoalStatus::Active,
|
|
token_budget: Some(123),
|
|
tokens_used: 45,
|
|
time_used_seconds: 6,
|
|
created_at: 7,
|
|
updated_at: 8,
|
|
},
|
|
}),
|
|
});
|
|
|
|
let envelope = timeout(Duration::from_secs(1), outgoing_rx.recv())
|
|
.await
|
|
.expect("timed out waiting for forwarded extension event")
|
|
.expect("outgoing channel closed unexpectedly");
|
|
let OutgoingEnvelope::Broadcast { message } = envelope else {
|
|
panic!("expected broadcast notification");
|
|
};
|
|
let OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(
|
|
notification,
|
|
)) = message
|
|
else {
|
|
panic!("expected thread goal updated notification");
|
|
};
|
|
|
|
assert_eq!(
|
|
ThreadGoalUpdatedNotification {
|
|
thread_id: thread_id.to_string(),
|
|
turn_id: Some("turn-1".to_string()),
|
|
goal: AppServerThreadGoal {
|
|
thread_id: thread_id.to_string(),
|
|
objective: "wire extension events".to_string(),
|
|
status: AppServerThreadGoalStatus::Active,
|
|
token_budget: Some(123),
|
|
tokens_used: 45,
|
|
time_used_seconds: 6,
|
|
created_at: 7,
|
|
updated_at: 8,
|
|
},
|
|
},
|
|
notification
|
|
);
|
|
}
|
|
}
|