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
@@ -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())