mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[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:
committed by
GitHub
Unverified
parent
d61dfeb23a
commit
78bab04116
@@ -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),
|
||||
|
||||
@@ -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("//")
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user