[1 of 3] Support long raw TUI goal objectives (#27508)

## Stack

1. **[1 of 3] Support long raw TUI goal objectives** - this PR
2. [2 of 3] Support long pasted text in TUI goals - #27509
3. [3 of 3] Support images in TUI goals - #27510

## Why

`thread/goal/set` limits persisted objective text to 4000 characters.
The TUI used to reject raw `/goal` objectives above that limit, even
though the client can make them usable by writing the long text to a
file and storing a short objective that points at that file.

This also needs to work for remote app-server sessions: filesystem API
calls must create files on the app-server host, and the stored path must
be meaningful to the agent on that host.

## What Changed

- Adds an app-server-host path helper so TUI code can build paths that
are resolved on the app-server host rather than the TUI host.
- Adds TUI app-server session helpers for `fs/createDirectory`,
`fs/writeFile`, `fs/readFile`, and `fs/remove` that work for embedded
and remote app-server sessions without changing the app-server protocol.
- Materializes oversized raw `/goal` objectives into
`$CODEX_HOME/attachments/<uuid>/goal-objective.md` through the
app-server filesystem APIs, then stores a short, readable objective that
directs the agent to that file.
- Reads managed objective files back for `/goal edit`. Other goal UI
renders the readable stored objective normally, without
managed-file-specific presentation logic.
- Recognizes managed references only when they name the expected
generated file under the app server's reported `$CODEX_HOME`, and cleans
up newly materialized files when goal replacement or setting does not
complete.

## Verification

- Added/updated TUI tests for raw oversized `/goal` submission, large
inline-paste expansion, queued oversized goals, app-facing
materialization before `thread/goal/set`, managed-path validation,
editing, and cleanup.
- Added/updated app-server-client remote coverage for initialized remote
Codex home handling.

## Manual Testing

- Ran the real TUI against a Unix-socket app server with different local
and server `$CODEX_HOME` directories. Oversized goals wrote only under
the server home, and persisted references used the server-canonical path
rather than the TUI path.
- Exercised 3,999-, 4,000-, and 4,001-character raw objectives. The
first two stayed inline without new files; the 4,001-character objective
became a managed objective file.
- Submitted a larger 8,275-character objective, verified its full
contents on the app-server host, and observed the goal continuation open
the referenced server-side file.
- Opened `/goal edit` for a managed objective and verified the full text
was restored through remote `fs/readFile`.
- Submitted an oversized replacement while a goal was active, verified
no file was written before confirmation, then canceled and confirmed
that the existing goal and attachment count were unchanged.
This commit is contained in:
Eric Traut
2026-06-11 22:26:31 -07:00
committed by GitHub
Unverified
parent d61dfeb23a
commit 78bab04116
17 changed files with 561 additions and 249 deletions
+14
View File
@@ -15,6 +15,7 @@
//! bridging async `mpsc` channels on both sides. Queues are bounded so overload
//! surfaces as channel-full errors rather than unbounded memory growth.
mod path;
mod remote;
use std::error::Error;
@@ -58,6 +59,7 @@ pub use codex_exec_server::EnvironmentManager;
pub use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::de::DeserializeOwned;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
@@ -65,6 +67,7 @@ use tokio::time::timeout;
use toml::Value as TomlValue;
use tracing::warn;
pub use crate::path::AppServerPath;
pub use crate::remote::RemoteAppServerClient;
pub use crate::remote::RemoteAppServerConnectArgs;
pub use crate::remote::RemoteAppServerEndpoint;
@@ -845,6 +848,15 @@ impl AppServerRequestHandle {
}
impl AppServerClient {
pub fn codex_home(&self, local_codex_home: &AbsolutePathBuf) -> Option<AppServerPath> {
match self {
Self::InProcess(_) => Some(AppServerPath::from_app_server(
local_codex_home.display().to_string(),
)),
Self::Remote(client) => client.codex_home().map(AppServerPath::from_app_server),
}
}
pub async fn request(&self, request: ClientRequest) -> IoResult<RequestResult> {
match self {
Self::InProcess(client) => client.request(request).await,
@@ -1110,6 +1122,7 @@ mod tests {
id: request.id,
result: serde_json::json!({
"userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust",
"codexHome": "/server/.codex",
}),
}),
)
@@ -1446,6 +1459,7 @@ mod tests {
.expect("remote client should connect");
assert_eq!(client.server_version(), Some("9.8.7-test"));
assert_eq!(client.codex_home(), Some("/server/.codex"));
let response: GetAccountResponse = client
.request_typed(ClientRequest::GetAccount {
request_id: RequestId::Integer(1),
+58
View File
@@ -0,0 +1,58 @@
//! Paths resolved using the app-server host's platform rules.
use std::fmt;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AppServerPath(String);
impl AppServerPath {
pub fn from_app_server(path: impl Into<String>) -> Self {
Self(path.into())
}
pub fn from_absolute_str(raw: &str) -> Option<Self> {
(raw.starts_with('/') || is_windows_absolute_path(raw)).then(|| Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn components(&self) -> Vec<&str> {
let separators = if is_windows_absolute_path(&self.0) {
&['/', '\\'][..]
} else {
&['/'][..]
};
self.0
.split(separators)
.filter(|part| !part.is_empty())
.collect()
}
pub fn join(&self, segment: impl AsRef<str>) -> Self {
let is_windows = is_windows_absolute_path(&self.0);
let (path, separator) = if is_windows {
(self.0.trim_end_matches(['/', '\\']), '\\')
} else {
(self.0.trim_end_matches('/'), '/')
};
Self(format!("{path}{separator}{}", segment.as_ref()))
}
}
impl fmt::Display for AppServerPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
fn is_windows_absolute_path(path: &str) -> bool {
let bytes = path.as_bytes();
(bytes.len() >= 3
&& bytes[0].is_ascii_alphabetic()
&& bytes[1] == b':'
&& matches!(bytes[2], b'\\' | b'/'))
|| path.starts_with("\\\\")
|| path.starts_with("//")
}
+27 -29
View File
@@ -124,7 +124,7 @@ pub(crate) fn websocket_url_supports_auth_token(url: &Url) -> bool {
enum RemoteClientCommand {
Request {
request: Box<ClientRequest>,
request: Box<JSONRPCRequest>,
response_tx: oneshot::Sender<IoResult<RequestResult>>,
},
Notify {
@@ -151,6 +151,7 @@ pub struct RemoteAppServerClient {
event_rx: mpsc::UnboundedReceiver<AppServerEvent>,
pending_events: VecDeque<AppServerEvent>,
server_version: Option<String>,
codex_home: Option<String>,
worker_handle: tokio::task::JoinHandle<()>,
}
@@ -185,6 +186,10 @@ impl RemoteAppServerClient {
self.server_version.as_deref()
}
pub fn codex_home(&self) -> Option<&str> {
self.codex_home.as_deref()
}
async fn connect_with_stream<S>(
channel_capacity: usize,
endpoint: String,
@@ -195,7 +200,7 @@ impl RemoteAppServerClient {
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
let mut stream = stream;
let (pending_events, server_version) = initialize_remote_connection(
let (pending_events, server_version, codex_home) = initialize_remote_connection(
&mut stream,
&endpoint,
initialize_params,
@@ -218,7 +223,7 @@ impl RemoteAppServerClient {
};
match command {
RemoteClientCommand::Request { request, response_tx } => {
let request_id = request_id_from_client_request(&request);
let request_id = request.id.clone();
if pending_requests.contains_key(&request_id) {
let _ = response_tx.send(Err(IoError::new(
ErrorKind::InvalidInput,
@@ -229,7 +234,7 @@ impl RemoteAppServerClient {
pending_requests.insert(request_id.clone(), response_tx);
if let Err(err) = write_jsonrpc_message(
&mut stream,
JSONRPCMessage::Request(jsonrpc_request_from_client_request(*request)),
JSONRPCMessage::Request(*request),
&endpoint,
)
.await
@@ -472,6 +477,7 @@ impl RemoteAppServerClient {
event_rx,
pending_events: pending_events.into(),
server_version,
codex_home,
worker_handle,
})
}
@@ -483,25 +489,7 @@ impl RemoteAppServerClient {
}
pub async fn request(&self, request: ClientRequest) -> IoResult<RequestResult> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(RemoteClientCommand::Request {
request: Box::new(request),
response_tx,
})
.await
.map_err(|_| {
IoError::new(
ErrorKind::BrokenPipe,
"remote app-server worker channel is closed",
)
})?;
response_rx.await.map_err(|_| {
IoError::new(
ErrorKind::BrokenPipe,
"remote app-server request channel is closed",
)
})?
self.request_handle().request(request).await
}
pub async fn request_typed<T>(&self, request: ClientRequest) -> Result<T, TypedRequestError>
@@ -613,6 +601,7 @@ impl RemoteAppServerClient {
event_rx,
pending_events: _pending_events,
server_version: _server_version,
codex_home: _codex_home,
worker_handle,
} = self;
let mut worker_handle = worker_handle;
@@ -637,6 +626,11 @@ impl RemoteAppServerClient {
impl RemoteAppServerRequestHandle {
pub async fn request(&self, request: ClientRequest) -> IoResult<RequestResult> {
self.request_json_rpc(jsonrpc_request_from_client_request(request))
.await
}
pub async fn request_json_rpc(&self, request: JSONRPCRequest) -> IoResult<RequestResult> {
let (response_tx, response_rx) = oneshot::channel();
self.command_tx
.send(RemoteClientCommand::Request {
@@ -800,13 +794,14 @@ async fn initialize_remote_connection<S>(
endpoint: &str,
params: InitializeParams,
initialize_timeout: Duration,
) -> IoResult<(Vec<AppServerEvent>, Option<String>)>
) -> IoResult<(Vec<AppServerEvent>, Option<String>, Option<String>)>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let initialize_request_id = RequestId::String("initialize".to_string());
let mut pending_events = Vec::new();
let mut server_version = None;
let mut codex_home = None;
write_jsonrpc_message(
stream,
JSONRPCMessage::Request(jsonrpc_request_from_client_request(
@@ -838,6 +833,12 @@ where
let (_, rest) = user_agent.split_once('/')?;
rest.split_whitespace().next().map(str::to_string)
});
codex_home = response
.result
.get("codexHome")
.and_then(serde_json::Value::as_str)
.filter(|codex_home| !codex_home.is_empty())
.map(str::to_string);
break Ok(());
}
JSONRPCMessage::Error(error) if error.id == initialize_request_id => {
@@ -929,7 +930,7 @@ where
)
.await?;
Ok((pending_events, server_version))
Ok((pending_events, server_version, codex_home))
}
fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option<AppServerEvent> {
@@ -951,10 +952,6 @@ fn deliver_event(
})
}
fn request_id_from_client_request(request: &ClientRequest) -> RequestId {
jsonrpc_request_from_client_request(request.clone()).id
}
fn jsonrpc_request_from_client_request(request: ClientRequest) -> JSONRPCRequest {
let value = match serde_json::to_value(request) {
Ok(value) => value,
@@ -1024,6 +1021,7 @@ mod tests {
event_rx,
pending_events: VecDeque::new(),
server_version: None,
codex_home: None,
worker_handle,
};