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
parent 0ee737cea6
commit 6c874f9b34
29 changed files with 1973 additions and 5 deletions
+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);