Add goal app-server API (2 / 5) (#18074)

Adds the app-server v2 goal API on top of the persisted goal state from
PR 1.

## Why

Clients need a stable app-server surface for reading and controlling
materialized thread goals before the model tools and TUI can use them.
Goal changes also need to be observable by app-server clients, including
clients that resume an existing thread.

## What changed

- Added v2 `thread/goal/get`, `thread/goal/set`, and `thread/goal/clear`
RPCs for materialized threads.
- Added `thread/goal/updated` and `thread/goal/cleared` notifications so
clients can keep local goal state in sync.
- Added resume/snapshot wiring so reconnecting clients see the current
goal state for a thread.
- Added app-server handlers that reconcile persisted rollout state
before direct goal mutations.
- Updated the app-server README plus generated JSON and TypeScript
schema fixtures for the new API surface.

## Verification

- Added app-server v2 coverage for goal get/set/clear behavior,
notification emission, resume snapshots, and non-local thread-store
interactions.
This commit is contained in:
Eric Traut
2026-04-24 20:53:41 -07:00
committed by GitHub
Unverified
parent 0ee737cea6
commit 6c874f9b34
29 changed files with 1973 additions and 5 deletions
@@ -78,6 +78,7 @@ use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::SkillsChangedNotification;
use codex_app_server_protocol::TerminalInteractionNotification;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadNameUpdatedNotification;
use codex_app_server_protocol::ThreadRealtimeClosedNotification;
@@ -1954,6 +1955,20 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
}
EventMsg::ThreadGoalUpdated(thread_goal_event) => {
if let ApiVersion::V2 = api_version {
let notification = ThreadGoalUpdatedNotification {
thread_id: thread_goal_event.thread_id.to_string(),
turn_id: thread_goal_event.turn_id,
goal: thread_goal_event.goal.clone().into(),
};
outgoing
.send_global_server_notification(ServerNotification::ThreadGoalUpdated(
notification,
))
.await;
}
}
EventMsg::TurnDiff(turn_diff_event) => {
handle_turn_diff(
conversation_id,
@@ -150,6 +150,16 @@ use codex_app_server_protocol::ThreadDecrementElicitationParams;
use codex_app_server_protocol::ThreadDecrementElicitationResponse;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadForkResponse;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalClearParams;
use codex_app_server_protocol::ThreadGoalClearResponse;
use codex_app_server_protocol::ThreadGoalClearedNotification;
use codex_app_server_protocol::ThreadGoalGetParams;
use codex_app_server_protocol::ThreadGoalGetResponse;
use codex_app_server_protocol::ThreadGoalSetParams;
use codex_app_server_protocol::ThreadGoalSetResponse;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_app_server_protocol::ThreadIncrementElicitationParams;
use codex_app_server_protocol::ThreadIncrementElicitationResponse;
use codex_app_server_protocol::ThreadInjectItemsParams;
@@ -482,6 +492,9 @@ enum ThreadReadViewError {
Internal(String),
}
mod thread_goal_handlers;
use self::thread_goal_handlers::api_thread_goal_from_state;
impl Drop for ActiveLogin {
fn drop(&mut self) {
self.cancel();
@@ -955,6 +968,18 @@ impl CodexMessageProcessor {
self.thread_set_name(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadGoalSet { request_id, params } => {
self.thread_goal_set(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadGoalGet { request_id, params } => {
self.thread_goal_get(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadGoalClear { request_id, params } => {
self.thread_goal_clear(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::ThreadMetadataUpdate { request_id, params } => {
self.thread_metadata_update(to_connection_request_id(request_id), params)
.await;
@@ -4695,6 +4720,9 @@ impl CodexMessageProcessor {
)
.await;
}
if self.config.features.enabled(Feature::Goals) {
self.emit_thread_goal_snapshot(thread_id).await;
}
}
Err(err) => {
let error = JSONRPCErrorError {
@@ -4860,6 +4888,17 @@ impl CodexMessageProcessor {
return true;
};
let emit_thread_goal_update = self.config.features.enabled(Feature::Goals);
let thread_goal_state_db = if emit_thread_goal_update {
if let Some(state_db) = existing_thread.state_db() {
Some(state_db)
} else {
open_state_db_for_direct_thread_lookup(&self.config).await
}
} else {
None
};
let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse(
Box::new(crate::thread_state::PendingThreadResumeRequest {
request_id: request_id.clone(),
@@ -4867,6 +4906,8 @@ impl CodexMessageProcessor {
config_snapshot,
instruction_sources,
thread_summary,
emit_thread_goal_update,
thread_goal_state_db,
include_turns: !params.exclude_turns,
}),
);
@@ -4879,6 +4920,7 @@ impl CodexMessageProcessor {
data: None,
};
self.outgoing.send_error(request_id, err).await;
return true;
}
return true;
}
@@ -8800,6 +8842,29 @@ async fn handle_thread_listener_command(
)
.await;
}
ThreadListenerCommand::EmitThreadGoalUpdated { goal } => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: conversation_id.to_string(),
turn_id: None,
goal,
},
))
.await;
}
ThreadListenerCommand::EmitThreadGoalCleared => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalCleared(
ThreadGoalClearedNotification {
thread_id: conversation_id.to_string(),
},
))
.await;
}
ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => {
send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await;
}
ThreadListenerCommand::ResolveServerRequest {
request_id,
completion_tx,
@@ -8964,11 +9029,56 @@ async fn handle_pending_thread_resume_request(
)
.await;
}
if pending.emit_thread_goal_update {
if let Some(state_db) = pending.thread_goal_state_db {
send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await;
} else {
tracing::warn!(
thread_id = %conversation_id,
"state db unavailable when reading thread goal for running thread resume"
);
}
}
outgoing
.replay_requests_to_connection_for_thread(connection_id, conversation_id)
.await;
}
async fn send_thread_goal_snapshot_notification(
outgoing: &Arc<OutgoingMessageSender>,
thread_id: ThreadId,
state_db: &StateDbHandle,
) {
match state_db.get_thread_goal(thread_id).await {
Ok(Some(goal)) => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: None,
goal: api_thread_goal_from_state(goal),
},
))
.await;
}
Ok(None) => {
outgoing
.send_server_notification(ServerNotification::ThreadGoalCleared(
ThreadGoalClearedNotification {
thread_id: thread_id.to_string(),
},
))
.await;
}
Err(err) => {
tracing::warn!(
thread_id = %thread_id,
"failed to read thread goal for resume snapshot: {err}"
);
}
}
}
enum ThreadTurnSource<'a> {
HistoryItems(&'a [RolloutItem]),
}
@@ -9459,6 +9569,27 @@ async fn open_state_db_for_direct_thread_lookup(config: &Config) -> Option<State
.ok()
}
fn invalid_request(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: message.into(),
data: None,
}
}
fn internal_error(message: impl Into<String>) -> JSONRPCErrorError {
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: message.into(),
data: None,
}
}
fn parse_thread_id_for_request(thread_id: &str) -> Result<ThreadId, JSONRPCErrorError> {
ThreadId::from_string(thread_id)
.map_err(|err| invalid_request(format!("invalid thread id: {err}")))
}
fn non_empty_title(metadata: &ThreadMetadata) -> Option<String> {
let title = metadata.title.trim();
(!title.is_empty()).then(|| title.to_string())
@@ -0,0 +1,466 @@
use super::*;
impl CodexMessageProcessor {
pub(super) async fn thread_goal_set(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalSetParams,
) {
if !self.config.features.enabled(Feature::Goals) {
self.send_invalid_request_error(request_id, "goals feature is disabled".to_string())
.await;
return;
}
let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) {
Ok(thread_id) => thread_id,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let running_thread = self.thread_manager.get_thread(thread_id).await.ok();
let rollout_path = match running_thread.as_ref() {
Some(thread) => match thread.rollout_path() {
Some(path) => path,
None => {
self.send_invalid_request_error(
request_id,
format!("ephemeral thread does not support goals: {thread_id}"),
)
.await;
return;
}
},
None => {
match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
{
Ok(Some(path)) => path,
Ok(None) => {
self.send_invalid_request_error(
request_id,
format!("thread not found: {thread_id}"),
)
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to locate thread id {thread_id}: {err}"),
)
.await;
return;
}
}
}
};
reconcile_rollout(
Some(&state_db),
rollout_path.as_path(),
self.config.model_provider_id.as_str(),
/*builder*/ None,
&[],
/*archived_only*/ None,
/*new_thread_memory_mode*/ None,
)
.await;
let listener_command_tx = {
let thread_state = self.thread_state_manager.thread_state(thread_id).await;
let thread_state = thread_state.lock().await;
thread_state.listener_command_tx()
};
let status = params.status.map(thread_goal_status_to_state);
let objective = params.objective.as_deref().map(str::trim);
if let Some(objective) = objective {
if objective.is_empty() {
self.send_invalid_request_error(
request_id,
"goal objective must not be empty".to_string(),
)
.await;
return;
}
if let Err(message) = validate_goal_budget(params.token_budget.flatten()) {
self.send_invalid_request_error(request_id, message).await;
return;
}
} else if let Some(token_budget) = params.token_budget
&& let Err(message) = validate_goal_budget(token_budget)
{
self.send_invalid_request_error(request_id, message).await;
return;
}
let goal = if let Some(objective) = objective {
match state_db.get_thread_goal(thread_id).await {
Ok(goal) => {
if let Some(goal) = goal.as_ref().filter(|goal| {
goal.objective == objective
&& goal.status != codex_state::ThreadGoalStatus::Complete
}) {
state_db
.update_thread_goal(
thread_id,
codex_state::ThreadGoalUpdate {
status,
token_budget: params.token_budget,
expected_goal_id: Some(goal.goal_id.clone()),
},
)
.await
.and_then(|goal| {
goal.ok_or_else(|| {
anyhow::anyhow!(
"cannot update goal for thread {thread_id}: no goal exists"
)
})
})
} else {
state_db
.replace_thread_goal(
thread_id,
objective,
status.unwrap_or(codex_state::ThreadGoalStatus::Active),
params.token_budget.flatten(),
)
.await
}
}
Err(err) => Err(err),
}
} else {
state_db
.update_thread_goal(
thread_id,
codex_state::ThreadGoalUpdate {
status,
token_budget: params.token_budget,
expected_goal_id: None,
},
)
.await
.and_then(|goal| {
goal.ok_or_else(|| {
anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists")
})
})
};
let goal = match goal {
Ok(goal) => goal,
Err(err) => {
self.send_invalid_request_error(request_id, err.to_string())
.await;
return;
}
};
let goal = api_thread_goal_from_state(goal);
self.outgoing
.send_response(
request_id.clone(),
ThreadGoalSetResponse { goal: goal.clone() },
)
.await;
self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx)
.await;
}
pub(super) async fn thread_goal_get(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalGetParams,
) {
if !self.config.features.enabled(Feature::Goals) {
self.send_invalid_request_error(request_id, "goals feature is disabled".to_string())
.await;
return;
}
let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) {
Ok(thread_id) => thread_id,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let goal = match state_db.get_thread_goal(thread_id).await {
Ok(goal) => goal.map(api_thread_goal_from_state),
Err(err) => {
self.send_internal_error(request_id, format!("failed to read thread goal: {err}"))
.await;
return;
}
};
self.outgoing
.send_response(request_id, ThreadGoalGetResponse { goal })
.await;
}
pub(super) async fn thread_goal_clear(
&self,
request_id: ConnectionRequestId,
params: ThreadGoalClearParams,
) {
if !self.config.features.enabled(Feature::Goals) {
self.send_invalid_request_error(request_id, "goals feature is disabled".to_string())
.await;
return;
}
let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) {
Ok(thread_id) => thread_id,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(error) => {
self.outgoing.send_error(request_id, error).await;
return;
}
};
let running_thread = self.thread_manager.get_thread(thread_id).await.ok();
let rollout_path = match running_thread.as_ref() {
Some(thread) => match thread.rollout_path() {
Some(path) => path,
None => {
self.send_invalid_request_error(
request_id,
format!("ephemeral thread does not support goals: {thread_id}"),
)
.await;
return;
}
},
None => {
match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string())
.await
{
Ok(Some(path)) => path,
Ok(None) => {
self.send_invalid_request_error(
request_id,
format!("thread not found: {thread_id}"),
)
.await;
return;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to locate thread id {thread_id}: {err}"),
)
.await;
return;
}
}
}
};
reconcile_rollout(
Some(&state_db),
rollout_path.as_path(),
self.config.model_provider_id.as_str(),
/*builder*/ None,
&[],
/*archived_only*/ None,
/*new_thread_memory_mode*/ None,
)
.await;
let listener_command_tx = {
let thread_state = self.thread_state_manager.thread_state(thread_id).await;
let thread_state = thread_state.lock().await;
thread_state.listener_command_tx()
};
let cleared = match state_db.delete_thread_goal(thread_id).await {
Ok(cleared) => cleared,
Err(err) => {
self.send_internal_error(request_id, format!("failed to clear thread goal: {err}"))
.await;
return;
}
};
self.outgoing
.send_response(request_id, ThreadGoalClearResponse { cleared })
.await;
if cleared {
self.emit_thread_goal_cleared_ordered(thread_id, listener_command_tx)
.await;
}
}
async fn state_db_for_materialized_thread(
&self,
thread_id: ThreadId,
) -> Result<StateDbHandle, JSONRPCErrorError> {
if let Ok(thread) = self.thread_manager.get_thread(thread_id).await {
if thread.rollout_path().is_none() {
return Err(invalid_request(format!(
"ephemeral thread does not support goals: {thread_id}"
)));
}
if let Some(state_db) = thread.state_db() {
return Ok(state_db);
}
} else {
match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await
{
Ok(Some(_)) => {}
Ok(None) => {
return Err(invalid_request(format!("thread not found: {thread_id}")));
}
Err(err) => {
return Err(internal_error(format!(
"failed to locate thread id {thread_id}: {err}"
)));
}
}
}
open_state_db_for_direct_thread_lookup(&self.config)
.await
.ok_or_else(|| internal_error("sqlite state db unavailable for thread goals"))
}
pub(super) async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) {
let state_db = match self.state_db_for_materialized_thread(thread_id).await {
Ok(state_db) => state_db,
Err(err) => {
warn!(
"failed to open state db before emitting thread goal resume snapshot for {thread_id}: {}",
err.message
);
return;
}
};
let listener_command_tx = {
let thread_state = self.thread_state_manager.thread_state(thread_id).await;
let thread_state = thread_state.lock().await;
thread_state.listener_command_tx()
};
if let Some(listener_command_tx) = listener_command_tx {
let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalSnapshot {
state_db: state_db.clone(),
};
if listener_command_tx.send(command).is_ok() {
return;
}
warn!(
"failed to enqueue thread goal snapshot for {thread_id}: listener command channel is closed"
);
}
send_thread_goal_snapshot_notification(&self.outgoing, thread_id, &state_db).await;
}
async fn emit_thread_goal_updated_ordered(
&self,
thread_id: ThreadId,
goal: ThreadGoal,
listener_command_tx: Option<tokio::sync::mpsc::UnboundedSender<ThreadListenerCommand>>,
) {
if let Some(listener_command_tx) = listener_command_tx {
let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalUpdated {
goal: goal.clone(),
};
if listener_command_tx.send(command).is_ok() {
return;
}
warn!(
"failed to enqueue thread goal update for {thread_id}: listener command channel is closed"
);
}
self.outgoing
.send_server_notification(ServerNotification::ThreadGoalUpdated(
ThreadGoalUpdatedNotification {
thread_id: thread_id.to_string(),
turn_id: None,
goal,
},
))
.await;
}
async fn emit_thread_goal_cleared_ordered(
&self,
thread_id: ThreadId,
listener_command_tx: Option<tokio::sync::mpsc::UnboundedSender<ThreadListenerCommand>>,
) {
if let Some(listener_command_tx) = listener_command_tx {
let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalCleared;
if listener_command_tx.send(command).is_ok() {
return;
}
warn!(
"failed to enqueue thread goal clear for {thread_id}: listener command channel is closed"
);
}
self.outgoing
.send_server_notification(ServerNotification::ThreadGoalCleared(
ThreadGoalClearedNotification {
thread_id: thread_id.to_string(),
},
))
.await;
}
}
fn validate_goal_budget(value: Option<i64>) -> Result<(), String> {
if let Some(value) = value
&& value <= 0
{
return Err("goal budgets must be positive when provided".to_string());
}
Ok(())
}
fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus {
match status {
ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active,
ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused,
ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited,
ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete,
}
}
fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus {
match status {
codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active,
codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused,
codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited,
codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete,
}
}
pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal {
ThreadGoal {
thread_id: goal.thread_id.to_string(),
objective: goal.objective,
status: thread_goal_status_from_state(goal.status),
token_budget: goal.token_budget,
tokens_used: goal.tokens_used,
time_used_seconds: goal.time_used_seconds,
created_at: goal.created_at.timestamp(),
updated_at: goal.updated_at.timestamp(),
}
}
+14
View File
@@ -1,6 +1,7 @@
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnError;
@@ -9,6 +10,7 @@ use codex_core::ThreadConfigSnapshot;
use codex_protocol::ThreadId;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_rollout::state_db::StateDbHandle;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::collections::HashSet;
@@ -31,6 +33,8 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) instruction_sources: Vec<AbsolutePathBuf>,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
pub(crate) emit_thread_goal_update: bool,
pub(crate) thread_goal_state_db: Option<StateDbHandle>,
pub(crate) include_turns: bool,
}
@@ -38,6 +42,16 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) enum ThreadListenerCommand {
// SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates.
SendThreadResumeResponse(Box<PendingThreadResumeRequest>),
// EmitThreadGoalUpdated is used to order app-server goal updates with running-thread resume responses.
EmitThreadGoalUpdated {
goal: ThreadGoal,
},
// EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses.
EmitThreadGoalCleared,
// EmitThreadGoalSnapshot is used to read and emit the latest goal state in the listener order.
EmitThreadGoalSnapshot {
state_db: StateDbHandle,
},
// ResolveServerRequest is used to notify the client that the request has been resolved.
// It is executed in the thread listener's context to ensure that the resolved notification is ordered with regard to the request itself.
ResolveServerRequest {
+98
View File
@@ -7,6 +7,7 @@ use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingError;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::QueuedOutgoingMessage;
use codex_app_server_protocol::ExperimentalApi;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::ServerRequest;
@@ -337,6 +338,13 @@ fn should_skip_notification_for_connection(
};
match message {
OutgoingMessage::AppServerNotification(notification) => {
if notification.experimental_reason().is_some()
&& !connection_state
.experimental_api_enabled
.load(Ordering::Acquire)
{
return true;
}
let method = notification.to_string();
opted_out_notification_methods.contains(method.as_str())
}
@@ -469,6 +477,9 @@ mod tests {
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadGoal;
use codex_app_server_protocol::ThreadGoalStatus;
use codex_app_server_protocol::ThreadGoalUpdatedNotification;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -479,6 +490,23 @@ mod tests {
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
}
fn thread_goal_updated_notification() -> ServerNotification {
ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "ship goal mode".to_string(),
status: ThreadGoalStatus::Active,
token_budget: None,
tokens_used: 0,
time_used_seconds: 0,
created_at: 1,
updated_at: 1,
},
})
}
#[test]
fn listen_off_parses_as_off_transport() {
assert_eq!(
@@ -810,6 +838,76 @@ mod tests {
));
}
#[tokio::test]
async fn experimental_notifications_are_dropped_without_capability() {
let connection_id = ConnectionId(12);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(false)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
write_complete_tx: None,
},
)
.await;
assert!(
writer_rx.try_recv().is_err(),
"experimental notifications should not reach clients without capability"
);
}
#[tokio::test]
async fn experimental_notifications_are_preserved_with_capability() {
let connection_id = ConnectionId(13);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
/*disconnect_sender*/ None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()),
write_complete_tx: None,
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("experimental notification should reach opted-in client");
assert!(matches!(
message.message,
OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_))
));
}
#[tokio::test]
async fn command_execution_request_approval_strips_additional_permissions_without_capability() {
let connection_id = ConnectionId(8);