[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
parent d61dfeb23a
commit 78bab04116
17 changed files with 561 additions and 249 deletions
+135
View File
@@ -0,0 +1,135 @@
use super::AppServerSession;
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use codex_app_server_client::AppServerPath;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::FsCreateDirectoryParams;
use codex_app_server_protocol::FsCreateDirectoryResponse;
use codex_app_server_protocol::FsReadFileParams;
use codex_app_server_protocol::FsReadFileResponse;
use codex_app_server_protocol::FsRemoveParams;
use codex_app_server_protocol::FsRemoveResponse;
use codex_app_server_protocol::FsWriteFileParams;
use codex_app_server_protocol::FsWriteFileResponse;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::RequestId;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use serde::de::DeserializeOwned;
use serde_json::json;
impl AppServerSession {
pub(crate) async fn fs_create_directory_all_path(
&mut self,
path: &AppServerPath,
) -> Result<()> {
self.request_fs_path::<FsCreateDirectoryResponse>(
"fs/createDirectory",
path,
|request_id, path| ClientRequest::FsCreateDirectory {
request_id,
params: FsCreateDirectoryParams {
path,
recursive: Some(true),
},
},
json!({ "path": path.as_str(), "recursive": true }),
)
.await
.map(drop)
}
pub(crate) async fn fs_write_file_path(
&mut self,
path: &AppServerPath,
bytes: Vec<u8>,
) -> Result<()> {
let data_base64 = STANDARD.encode(bytes);
self.request_fs_path::<FsWriteFileResponse>(
"fs/writeFile",
path,
|request_id, path| ClientRequest::FsWriteFile {
request_id,
params: FsWriteFileParams {
path,
data_base64: data_base64.clone(),
},
},
json!({ "path": path.as_str(), "dataBase64": data_base64 }),
)
.await
.map(drop)
}
pub(crate) async fn fs_read_file_path(&mut self, path: &AppServerPath) -> Result<Vec<u8>> {
let response: FsReadFileResponse = self
.request_fs_path(
"fs/readFile",
path,
|request_id, path| ClientRequest::FsReadFile {
request_id,
params: FsReadFileParams { path },
},
json!({ "path": path.as_str() }),
)
.await?;
STANDARD
.decode(response.data_base64)
.wrap_err("fs/readFile returned invalid base64 data")
}
pub(crate) async fn fs_remove_path(&mut self, path: &AppServerPath) -> Result<()> {
self.request_fs_path::<FsRemoveResponse>(
"fs/remove",
path,
|request_id, path| ClientRequest::FsRemove {
request_id,
params: FsRemoveParams {
path,
recursive: None,
force: None,
},
},
json!({ "path": path.as_str() }),
)
.await
.map(drop)
}
async fn request_fs_path<T: DeserializeOwned>(
&mut self,
method: &str,
path: &AppServerPath,
local_request: impl FnOnce(RequestId, AbsolutePathBuf) -> ClientRequest,
remote_params: serde_json::Value,
) -> Result<T> {
let request_id = self.next_request_id();
match self.request_handle() {
AppServerRequestHandle::Remote(handle) => {
let response = handle
.request_json_rpc(JSONRPCRequest {
id: request_id,
method: method.to_string(),
params: Some(remote_params),
trace: None,
})
.await
.wrap_err_with(|| format!("{method} failed in TUI"))?;
serde_json::from_value(response.map_err(|source| {
color_eyre::eyre::eyre!("{method} failed in TUI: {}", source.message)
})?)
.wrap_err_with(|| format!("{method} returned invalid data"))
}
AppServerRequestHandle::InProcess(_) => {
let path = AbsolutePathBuf::from_absolute_path_checked(path.as_str())
.wrap_err_with(|| format!("invalid local app-server fs path {path}"))?;
self.client
.request_typed(local_request(request_id, path))
.await
.wrap_err_with(|| format!("{method} failed in TUI"))
}
}
}
}