mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
## What changed - Add `codex queue --thread <THREAD> --message <TEXT>` to submit a text message through the `thread/queue/add` app-server API. - Resolve active sessions by UUID or exact name across interactive, exec, and custom sources, and reject ambiguous names. - Support local and explicit remote app servers while reporting incompatible servers and configuration overrides instead of silently changing the target. - Reject empty messages and image attachments. ## Testing - Add CLI coverage for remote submission, validation, unsupported servers, and local-daemon routing. - Add session lookup and queue tests for stale, non-interactive, custom, and duplicate session names. GitOrigin-RevId: 44c233f752b63a85c85b92fa6da303336e4ddeca
272 lines
8.3 KiB
Rust
272 lines
8.3 KiB
Rust
use std::path::Path;
|
|
use std::process::Output;
|
|
|
|
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use futures::SinkExt;
|
|
use futures::StreamExt;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
use tokio_tungstenite::accept_async;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
const THREAD_ID: &str = "123e4567-e89b-12d3-a456-426614174000";
|
|
|
|
enum QueueResponse {
|
|
Success,
|
|
MethodNotFound,
|
|
#[cfg(unix)]
|
|
UnknownMethodVariant,
|
|
}
|
|
|
|
async fn respond_to_queue_request<S>(
|
|
stream: S,
|
|
codex_home: &Path,
|
|
response: QueueResponse,
|
|
) -> Result<Value>
|
|
where
|
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
|
|
{
|
|
let mut websocket = accept_async(stream).await?;
|
|
let initialize = websocket
|
|
.next()
|
|
.await
|
|
.context("missing initialize request")??;
|
|
let initialize: Value = serde_json::from_str(initialize.to_text()?)?;
|
|
assert_eq!(initialize["method"], "initialize");
|
|
assert_eq!(
|
|
initialize["params"]["capabilities"]["experimentalApi"],
|
|
true
|
|
);
|
|
let initialized_response = json!({
|
|
"id": initialize["id"],
|
|
"result": {
|
|
"userAgent": "codex_cli_rs/0.0.0-test",
|
|
"codexHome": codex_home,
|
|
},
|
|
});
|
|
websocket
|
|
.send(Message::Text(initialized_response.to_string().into()))
|
|
.await?;
|
|
|
|
let initialized = websocket
|
|
.next()
|
|
.await
|
|
.context("missing initialized notification")??;
|
|
let initialized: Value = serde_json::from_str(initialized.to_text()?)?;
|
|
assert_eq!(initialized["method"], "initialized");
|
|
|
|
let request = websocket.next().await.context("missing queue request")??;
|
|
let request: Value = serde_json::from_str(request.to_text()?)?;
|
|
assert_eq!(request["method"], "thread/queue/add");
|
|
|
|
let result = match response {
|
|
QueueResponse::Success => json!({
|
|
"id": request["id"],
|
|
"result": {
|
|
"queuedSubmission": {
|
|
"id": "queued-submission-id",
|
|
"input": request["params"]["input"],
|
|
"clientUserMessageId": request["params"]["clientUserMessageId"],
|
|
},
|
|
},
|
|
}),
|
|
QueueResponse::MethodNotFound => json!({
|
|
"id": request["id"],
|
|
"error": { "code": -32601, "message": "Method not found" },
|
|
}),
|
|
#[cfg(unix)]
|
|
QueueResponse::UnknownMethodVariant => json!({
|
|
"id": request["id"],
|
|
"error": {
|
|
"code": -32600,
|
|
"message": "Invalid request: unknown variant `thread/queue/add`, expected `thread/list`",
|
|
},
|
|
}),
|
|
};
|
|
websocket
|
|
.send(Message::Text(result.to_string().into()))
|
|
.await?;
|
|
Ok(request)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_submits_message_to_remote_app_server() -> Result<()> {
|
|
let (output, request) = run_remote_queue_command(QueueResponse::Success).await?;
|
|
|
|
assert!(
|
|
output.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&output.stderr)
|
|
);
|
|
assert_eq!(request["params"]["threadId"], THREAD_ID);
|
|
assert_eq!(request["params"]["input"][0]["text"], "do the thing");
|
|
assert_eq!(
|
|
String::from_utf8(output.stdout)?,
|
|
format!("Queued message queued-submission-id for thread {THREAD_ID}.\n")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn queue_does_not_fallback_from_unsupported_explicit_remote() -> Result<()> {
|
|
let (output, _) = run_remote_queue_command(QueueResponse::MethodNotFound).await?;
|
|
|
|
assert!(!output.status.success());
|
|
assert!(
|
|
String::from_utf8(output.stderr)?
|
|
.contains("remote app server does not support thread/queue/add")
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn queue_rejects_empty_message() -> Result<()> {
|
|
let output = std::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
|
.args(["queue", "--thread", THREAD_ID, "--message", ""])
|
|
.output()?;
|
|
assert!(!output.status.success());
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn queue_rejects_image_attachments() -> Result<()> {
|
|
let output = std::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
|
.args([
|
|
"queue",
|
|
"--thread",
|
|
THREAD_ID,
|
|
"--message",
|
|
"do the thing",
|
|
"--image",
|
|
"screenshot.png",
|
|
])
|
|
.output()?;
|
|
assert!(!output.status.success());
|
|
assert!(String::from_utf8(output.stderr)?.contains("does not support image attachments"));
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_remote_queue_command(response: QueueResponse) -> Result<(Output, Value)> {
|
|
let codex_home = TempDir::new()?;
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let endpoint = format!("ws://{}", listener.local_addr()?);
|
|
let remote_first = matches!(response, QueueResponse::MethodNotFound);
|
|
let server_home = codex_home.path().to_path_buf();
|
|
let server = tokio::spawn(async move {
|
|
let (stream, _) = listener.accept().await?;
|
|
respond_to_queue_request(stream, server_home.as_path(), response).await
|
|
});
|
|
|
|
let remote_args = if remote_first {
|
|
[
|
|
"--remote",
|
|
endpoint.as_str(),
|
|
"--remote-auth-token-env",
|
|
"CODEX_REMOTE_TOKEN",
|
|
"queue",
|
|
]
|
|
} else {
|
|
[
|
|
"queue",
|
|
"--remote",
|
|
endpoint.as_str(),
|
|
"--remote-auth-token-env",
|
|
"CODEX_REMOTE_TOKEN",
|
|
]
|
|
};
|
|
let output = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
|
.env("CODEX_HOME", codex_home.path())
|
|
.env("CODEX_REMOTE_TOKEN", "test-token")
|
|
.args(remote_args)
|
|
.args(["--thread", THREAD_ID, "--message", "do the thing"])
|
|
.output()
|
|
.await?;
|
|
Ok((output, server.await??))
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn queue_rejects_local_daemon_that_does_not_support_queueing() -> Result<()> {
|
|
let codex_home = tempfile::tempdir_in("/tmp")?;
|
|
let socket_path = codex_app_server::app_server_control_socket_path(codex_home.path())?;
|
|
std::fs::create_dir_all(
|
|
socket_path
|
|
.as_path()
|
|
.parent()
|
|
.context("missing socket parent")?,
|
|
)?;
|
|
let listener = tokio::net::UnixListener::bind(socket_path.as_path())?;
|
|
let server_home = codex_home.path().to_path_buf();
|
|
let server = tokio::spawn(async move {
|
|
let (probe, _) = listener.accept().await?;
|
|
drop(probe);
|
|
let (stream, _) = listener.accept().await?;
|
|
respond_to_queue_request(
|
|
stream,
|
|
server_home.as_path(),
|
|
QueueResponse::UnknownMethodVariant,
|
|
)
|
|
.await
|
|
});
|
|
|
|
let output = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
|
.env("CODEX_HOME", codex_home.path())
|
|
.args(["queue", "--thread", THREAD_ID, "--message", "do the thing"])
|
|
.output()
|
|
.await?;
|
|
server.await??;
|
|
|
|
assert!(!output.status.success());
|
|
assert!(
|
|
String::from_utf8(output.stderr)?
|
|
.contains("local app-server daemon does not support thread/queue/add")
|
|
);
|
|
assert!(!codex_home.path().join("queue_1.sqlite").exists());
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[tokio::test]
|
|
async fn queue_rejects_overrides_that_bypass_local_daemon() -> Result<()> {
|
|
let codex_home = tempfile::tempdir_in("/tmp")?;
|
|
let socket_path = codex_app_server::app_server_control_socket_path(codex_home.path())?;
|
|
std::fs::create_dir_all(
|
|
socket_path
|
|
.as_path()
|
|
.parent()
|
|
.context("missing socket parent")?,
|
|
)?;
|
|
let listener = tokio::net::UnixListener::bind(socket_path.as_path())?;
|
|
let server = tokio::spawn(async move {
|
|
let (probe, _) = listener.accept().await?;
|
|
drop(probe);
|
|
Ok::<_, std::io::Error>(())
|
|
});
|
|
|
|
let output = tokio::process::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
|
.env("CODEX_HOME", codex_home.path())
|
|
.args([
|
|
"queue",
|
|
"-c",
|
|
"model=\"test-model\"",
|
|
"--thread",
|
|
THREAD_ID,
|
|
"--message",
|
|
"do the thing",
|
|
])
|
|
.output()
|
|
.await?;
|
|
server.await??;
|
|
|
|
assert!(!output.status.success());
|
|
assert!(
|
|
String::from_utf8(output.stderr)?
|
|
.contains("embedded app server while a local app-server daemon is running")
|
|
);
|
|
Ok(())
|
|
}
|