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
@@ -285,6 +285,21 @@ client_request_definitions! {
params: v2::ThreadSetNameParams,
response: v2::ThreadSetNameResponse,
},
#[experimental("thread/goal/set")]
ThreadGoalSet => "thread/goal/set" {
params: v2::ThreadGoalSetParams,
response: v2::ThreadGoalSetResponse,
},
#[experimental("thread/goal/get")]
ThreadGoalGet => "thread/goal/get" {
params: v2::ThreadGoalGetParams,
response: v2::ThreadGoalGetResponse,
},
#[experimental("thread/goal/clear")]
ThreadGoalClear => "thread/goal/clear" {
params: v2::ThreadGoalClearParams,
response: v2::ThreadGoalClearResponse,
},
ThreadMetadataUpdate => "thread/metadata/update" {
params: v2::ThreadMetadataUpdateParams,
response: v2::ThreadMetadataUpdateResponse,
@@ -1027,6 +1042,10 @@ server_notification_definitions! {
ThreadClosed => "thread/closed" (v2::ThreadClosedNotification),
SkillsChanged => "skills/changed" (v2::SkillsChangedNotification),
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
#[experimental("thread/goal/updated")]
ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification),
#[experimental("thread/goal/cleared")]
ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification),
ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification),
TurnStarted => "turn/started" (v2::TurnStartedNotification),
HookStarted => "hook/started" (v2::HookStartedNotification),
@@ -2046,6 +2065,76 @@ mod tests {
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
assert_eq!(reason, Some("thread/realtime/start"));
}
#[test]
fn thread_goal_methods_are_marked_experimental() {
let set_request = ClientRequest::ThreadGoalSet {
request_id: RequestId::Integer(1),
params: v2::ThreadGoalSetParams {
thread_id: "thr_123".to_string(),
objective: Some("ship goal mode".to_string()),
status: Some(v2::ThreadGoalStatus::Active),
token_budget: Some(Some(10_000)),
},
};
let get_request = ClientRequest::ThreadGoalGet {
request_id: RequestId::Integer(2),
params: v2::ThreadGoalGetParams {
thread_id: "thr_123".to_string(),
},
};
let clear_request = ClientRequest::ThreadGoalClear {
request_id: RequestId::Integer(3),
params: v2::ThreadGoalClearParams {
thread_id: "thr_123".to_string(),
},
};
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&set_request),
Some("thread/goal/set")
);
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&get_request),
Some("thread/goal/get")
);
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request),
Some("thread/goal/clear")
);
}
#[test]
fn thread_goal_notifications_are_marked_experimental() {
let goal = v2::ThreadGoal {
thread_id: "thr_123".to_string(),
objective: "ship goal mode".to_string(),
status: v2::ThreadGoalStatus::Active,
token_budget: Some(10_000),
tokens_used: 123,
time_used_seconds: 45,
created_at: 1_700_000_000,
updated_at: 1_700_000_123,
};
let updated = ServerNotification::ThreadGoalUpdated(v2::ThreadGoalUpdatedNotification {
thread_id: "thr_123".to_string(),
turn_id: None,
goal,
});
let cleared = ServerNotification::ThreadGoalCleared(v2::ThreadGoalClearedNotification {
thread_id: "thr_123".to_string(),
});
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&updated),
Some("thread/goal/updated")
);
assert_eq!(
crate::experimental_api::ExperimentalApi::experimental_reason(&cleared),
Some("thread/goal/cleared")
);
}
#[test]
fn thread_realtime_started_notification_is_marked_experimental() {
let notification =
@@ -96,6 +96,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata;
use codex_protocol::protocol::SkillScope as CoreSkillScope;
use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency;
use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource;
use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus;
use codex_protocol::protocol::TokenUsage as CoreTokenUsage;
use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo;
use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope;
@@ -3747,6 +3748,103 @@ pub struct ThreadUnarchiveParams {
#[ts(export_to = "v2/")]
pub struct ThreadSetNameResponse {}
v2_enum_from_core! {
pub enum ThreadGoalStatus from CoreThreadGoalStatus {
Active,
Paused,
BudgetLimited,
Complete,
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoal {
pub thread_id: String,
pub objective: String,
pub status: ThreadGoalStatus,
#[ts(type = "number | null")]
pub token_budget: Option<i64>,
#[ts(type = "number")]
pub tokens_used: i64,
#[ts(type = "number")]
pub time_used_seconds: i64,
#[ts(type = "number")]
pub created_at: i64,
#[ts(type = "number")]
pub updated_at: i64,
}
impl From<codex_protocol::protocol::ThreadGoal> for ThreadGoal {
fn from(value: codex_protocol::protocol::ThreadGoal) -> Self {
Self {
thread_id: value.thread_id.to_string(),
objective: value.objective,
status: value.status.into(),
token_budget: value.token_budget,
tokens_used: value.tokens_used,
time_used_seconds: value.time_used_seconds,
created_at: value.created_at,
updated_at: value.updated_at,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalSetParams {
pub thread_id: String,
#[ts(optional = nullable)]
pub objective: Option<String>,
#[ts(optional = nullable)]
pub status: Option<ThreadGoalStatus>,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable, type = "number | null")]
pub token_budget: Option<Option<i64>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalSetResponse {
pub goal: ThreadGoal,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalGetParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalGetResponse {
pub goal: Option<ThreadGoal>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalClearParams {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalClearResponse {
pub cleared: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -6270,6 +6368,22 @@ pub struct ThreadNameUpdatedNotification {
pub thread_name: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalUpdatedNotification {
pub thread_id: String,
pub turn_id: Option<String>,
pub goal: ThreadGoal,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadGoalClearedNotification {
pub thread_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]