mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-20 17:02:18 +00:00
feat: implement TUI control queue and response handling
This commit is contained in:
parent
8c7cfd12b3
commit
2b1ddc2e02
7 changed files with 469 additions and 21 deletions
1
.turbo
Symbolic link
1
.turbo
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/.turbo
|
||||||
1
dist
Symbolic link
1
dist
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/dist
|
||||||
1
node_modules
Symbolic link
1
node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/node_modules
|
||||||
|
|
@ -460,6 +460,51 @@ struct DirectoryQuery {
|
||||||
directory: Option<String>,
|
directory: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TuiAppendPromptRequest {
|
||||||
|
text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TuiExecuteCommandRequest {
|
||||||
|
command: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TuiShowToastRequest {
|
||||||
|
title: Option<String>,
|
||||||
|
message: String,
|
||||||
|
variant: String,
|
||||||
|
duration: Option<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TuiSelectSessionRequest {
|
||||||
|
#[serde(rename = "sessionID")]
|
||||||
|
session_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TuiPublishRequest {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
event_type: String,
|
||||||
|
properties: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct TuiControlResponseRequest {
|
||||||
|
#[serde(rename = "requestID")]
|
||||||
|
request_id: String,
|
||||||
|
body: Option<Value>,
|
||||||
|
error: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, IntoParams)]
|
#[derive(Debug, Deserialize, IntoParams)]
|
||||||
struct ToolQuery {
|
struct ToolQuery {
|
||||||
directory: Option<String>,
|
directory: Option<String>,
|
||||||
|
|
@ -4088,29 +4133,79 @@ async fn oc_skill_list() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_next() -> impl IntoResponse {
|
async fn oc_tui_next(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let next = state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.next_tui_control(directory)
|
||||||
|
.await;
|
||||||
|
if let Some(request) = next {
|
||||||
|
return (
|
||||||
|
StatusCode::OK,
|
||||||
|
Json(json!({
|
||||||
|
"path": request.path,
|
||||||
|
"body": request.body,
|
||||||
|
"requestID": request.id,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
(StatusCode::OK, Json(json!({"path": "", "body": {}})))
|
(StatusCode::OK, Json(json!({"path": "", "body": {}})))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/tui/control/response",
|
path = "/tui/control/response",
|
||||||
request_body = String,
|
request_body = TuiControlResponseRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_response() -> impl IntoResponse {
|
async fn oc_tui_response(
|
||||||
bool_ok(true)
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<TuiControlResponseRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let response = json!({
|
||||||
|
"body": body.body,
|
||||||
|
"error": body.error,
|
||||||
|
});
|
||||||
|
let accepted = state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.respond_tui_control(directory, &body.request_id, response)
|
||||||
|
.await;
|
||||||
|
bool_ok(accepted)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/tui/append-prompt",
|
path = "/tui/append-prompt",
|
||||||
request_body = String,
|
request_body = TuiAppendPromptRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_append_prompt() -> impl IntoResponse {
|
async fn oc_tui_append_prompt(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<TuiAppendPromptRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(
|
||||||
|
directory,
|
||||||
|
"/tui/append-prompt".to_string(),
|
||||||
|
json!({"text": body.text}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4120,7 +4215,17 @@ async fn oc_tui_append_prompt() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_open_help() -> impl IntoResponse {
|
async fn oc_tui_open_help(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(directory, "/tui/open-help".to_string(), json!({}))
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4130,7 +4235,17 @@ async fn oc_tui_open_help() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_open_sessions() -> impl IntoResponse {
|
async fn oc_tui_open_sessions(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(directory, "/tui/open-sessions".to_string(), json!({}))
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4140,7 +4255,17 @@ async fn oc_tui_open_sessions() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_open_themes() -> impl IntoResponse {
|
async fn oc_tui_open_themes(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(directory, "/tui/open-themes".to_string(), json!({}))
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4150,18 +4275,37 @@ async fn oc_tui_open_themes() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_open_models() -> impl IntoResponse {
|
async fn oc_tui_open_models(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(directory, "/tui/open-models".to_string(), json!({}))
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/tui/submit-prompt",
|
path = "/tui/submit-prompt",
|
||||||
request_body = String,
|
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_submit_prompt() -> impl IntoResponse {
|
async fn oc_tui_submit_prompt(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(directory, "/tui/submit-prompt".to_string(), json!({}))
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4171,51 +4315,179 @@ async fn oc_tui_submit_prompt() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_clear_prompt() -> impl IntoResponse {
|
async fn oc_tui_clear_prompt(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(directory, "/tui/clear-prompt".to_string(), json!({}))
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/tui/execute-command",
|
path = "/tui/execute-command",
|
||||||
request_body = String,
|
request_body = TuiExecuteCommandRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_execute_command() -> impl IntoResponse {
|
async fn oc_tui_execute_command(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<TuiExecuteCommandRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(
|
||||||
|
directory,
|
||||||
|
"/tui/execute-command".to_string(),
|
||||||
|
json!({"command": body.command}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/tui/show-toast",
|
path = "/tui/show-toast",
|
||||||
request_body = String,
|
request_body = TuiShowToastRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_show_toast() -> impl IntoResponse {
|
async fn oc_tui_show_toast(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<TuiShowToastRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(
|
||||||
|
directory,
|
||||||
|
"/tui/show-toast".to_string(),
|
||||||
|
json!({
|
||||||
|
"title": body.title,
|
||||||
|
"message": body.message,
|
||||||
|
"variant": body.variant,
|
||||||
|
"duration": body.duration,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/tui/publish",
|
path = "/tui/publish",
|
||||||
request_body = String,
|
request_body = TuiPublishRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_publish() -> impl IntoResponse {
|
async fn oc_tui_publish(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<TuiPublishRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let (path, payload) = match body.event_type.as_str() {
|
||||||
|
"tui.prompt.append" => {
|
||||||
|
let Some(text) = body.properties.get("text").and_then(|v| v.as_str()) else {
|
||||||
|
return bad_request("text is required").into_response();
|
||||||
|
};
|
||||||
|
("/tui/append-prompt", json!({"text": text}))
|
||||||
|
}
|
||||||
|
"tui.command.execute" => {
|
||||||
|
let Some(command) = body
|
||||||
|
.properties
|
||||||
|
.get("command")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
else {
|
||||||
|
return bad_request("command is required").into_response();
|
||||||
|
};
|
||||||
|
("/tui/execute-command", json!({"command": command}))
|
||||||
|
}
|
||||||
|
"tui.toast.show" => {
|
||||||
|
let Some(message) = body.properties.get("message").and_then(|v| v.as_str()) else {
|
||||||
|
return bad_request("message is required").into_response();
|
||||||
|
};
|
||||||
|
let Some(variant) = body.properties.get("variant").and_then(|v| v.as_str()) else {
|
||||||
|
return bad_request("variant is required").into_response();
|
||||||
|
};
|
||||||
|
let title = body.properties.get("title").cloned();
|
||||||
|
let duration = body.properties.get("duration").cloned();
|
||||||
|
(
|
||||||
|
"/tui/show-toast",
|
||||||
|
json!({
|
||||||
|
"title": title,
|
||||||
|
"message": message,
|
||||||
|
"variant": variant,
|
||||||
|
"duration": duration,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
"tui.session.select" => {
|
||||||
|
let Some(session_id) = body
|
||||||
|
.properties
|
||||||
|
.get("sessionID")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
else {
|
||||||
|
return bad_request("sessionID is required").into_response();
|
||||||
|
};
|
||||||
|
let sessions = state.opencode.sessions.lock().await;
|
||||||
|
if !sessions.contains_key(session_id) {
|
||||||
|
return not_found("session not found").into_response();
|
||||||
|
}
|
||||||
|
("/tui/select-session", json!({"sessionID": session_id}))
|
||||||
|
}
|
||||||
|
_ => return bad_request("unsupported TUI event").into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(directory, path.to_string(), payload)
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/tui/select-session",
|
path = "/tui/select-session",
|
||||||
request_body = String,
|
request_body = TuiSelectSessionRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tui_select_session() -> impl IntoResponse {
|
async fn oc_tui_select_session(
|
||||||
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Query(query): Query<DirectoryQuery>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Json(body): Json<TuiSelectSessionRequest>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let directory = state.opencode.directory_for(&headers, query.directory.as_ref());
|
||||||
|
let sessions = state.opencode.sessions.lock().await;
|
||||||
|
if !sessions.contains_key(&body.session_id) {
|
||||||
|
return not_found("session not found").into_response();
|
||||||
|
}
|
||||||
|
state
|
||||||
|
.inner
|
||||||
|
.session_manager()
|
||||||
|
.enqueue_tui_control(
|
||||||
|
directory,
|
||||||
|
"/tui/select-session".to_string(),
|
||||||
|
json!({"sessionID": body.session_id}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
bool_ok(true)
|
bool_ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ use sandbox_agent_agent_management::credentials::{
|
||||||
|
|
||||||
const MOCK_EVENT_DELAY_MS: u64 = 200;
|
const MOCK_EVENT_DELAY_MS: u64 = 200;
|
||||||
static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
static TUI_CONTROL_COUNTER: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
|
|
@ -360,6 +361,20 @@ struct PendingQuestion {
|
||||||
options: Vec<String>,
|
options: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct TuiControlRequest {
|
||||||
|
pub(crate) id: String,
|
||||||
|
pub(crate) path: String,
|
||||||
|
pub(crate) body: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TuiControlQueue {
|
||||||
|
pending: VecDeque<TuiControlRequest>,
|
||||||
|
inflight: HashMap<String, TuiControlRequest>,
|
||||||
|
responses: HashMap<String, Value>,
|
||||||
|
}
|
||||||
|
|
||||||
impl SessionState {
|
impl SessionState {
|
||||||
fn new(
|
fn new(
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
|
@ -818,6 +833,7 @@ pub(crate) struct SessionManager {
|
||||||
sessions: Mutex<Vec<SessionState>>,
|
sessions: Mutex<Vec<SessionState>>,
|
||||||
server_manager: Arc<AgentServerManager>,
|
server_manager: Arc<AgentServerManager>,
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
|
tui_controls: Mutex<HashMap<String, TuiControlQueue>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
||||||
|
|
@ -1538,6 +1554,7 @@ impl SessionManager {
|
||||||
sessions: Mutex::new(Vec::new()),
|
sessions: Mutex::new(Vec::new()),
|
||||||
server_manager,
|
server_manager,
|
||||||
http_client: Client::new(),
|
http_client: Client::new(),
|
||||||
|
tui_controls: Mutex::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1960,6 +1977,59 @@ impl SessionManager {
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn enqueue_tui_control(
|
||||||
|
&self,
|
||||||
|
directory: String,
|
||||||
|
path: String,
|
||||||
|
body: Value,
|
||||||
|
) -> String {
|
||||||
|
let id = format!(
|
||||||
|
"tui_{}",
|
||||||
|
TUI_CONTROL_COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||||
|
);
|
||||||
|
let request = TuiControlRequest {
|
||||||
|
id: id.clone(),
|
||||||
|
path,
|
||||||
|
body,
|
||||||
|
};
|
||||||
|
let mut controls = self.tui_controls.lock().await;
|
||||||
|
let queue = controls.entry(directory).or_default();
|
||||||
|
queue.pending.push_back(request);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn next_tui_control(
|
||||||
|
&self,
|
||||||
|
directory: String,
|
||||||
|
) -> Option<TuiControlRequest> {
|
||||||
|
let mut controls = self.tui_controls.lock().await;
|
||||||
|
let queue = controls.entry(directory).or_default();
|
||||||
|
if let Some(request) = queue.pending.pop_front() {
|
||||||
|
queue
|
||||||
|
.inflight
|
||||||
|
.insert(request.id.clone(), request.clone());
|
||||||
|
return Some(request);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn respond_tui_control(
|
||||||
|
&self,
|
||||||
|
directory: String,
|
||||||
|
request_id: &str,
|
||||||
|
response: Value,
|
||||||
|
) -> bool {
|
||||||
|
let mut controls = self.tui_controls.lock().await;
|
||||||
|
let Some(queue) = controls.get_mut(&directory) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if queue.inflight.remove(request_id).is_some() {
|
||||||
|
queue.responses.insert(request_id.to_string(), response);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
async fn subscribe_for_turn(
|
async fn subscribe_for_turn(
|
||||||
&self,
|
&self,
|
||||||
session_id: &str,
|
session_id: &str,
|
||||||
|
|
|
||||||
102
server/packages/sandbox-agent/tests/opencode-compat/tui.test.ts
Normal file
102
server/packages/sandbox-agent/tests/opencode-compat/tui.test.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
/**
|
||||||
|
* Tests for OpenCode-compatible TUI control endpoints.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest";
|
||||||
|
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk";
|
||||||
|
import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn";
|
||||||
|
|
||||||
|
type TuiClient = {
|
||||||
|
appendPrompt: (args: unknown) => Promise<{ data?: unknown }>;
|
||||||
|
executeCommand: (args: unknown) => Promise<{ data?: unknown }>;
|
||||||
|
showToast: (args: unknown) => Promise<{ data?: unknown }>;
|
||||||
|
control: {
|
||||||
|
next: (args?: unknown) => Promise<{ data?: unknown }>;
|
||||||
|
response: (args: unknown) => Promise<{ data?: unknown }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("OpenCode-compatible TUI Control API", () => {
|
||||||
|
let handle: SandboxAgentHandle;
|
||||||
|
let client: OpencodeClient;
|
||||||
|
let tui: TuiClient;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await buildSandboxAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
handle = await spawnSandboxAgent({ opencodeCompat: true });
|
||||||
|
client = createOpencodeClient({
|
||||||
|
baseUrl: `${handle.baseUrl}/opencode`,
|
||||||
|
headers: { Authorization: `Bearer ${handle.token}` },
|
||||||
|
});
|
||||||
|
tui = client.tui as unknown as TuiClient;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await handle?.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues TUI control requests in order", async () => {
|
||||||
|
await tui.appendPrompt({ body: { text: "First" } });
|
||||||
|
await tui.executeCommand({ body: { command: "prompt.clear" } });
|
||||||
|
await tui.showToast({ body: { message: "Hello", variant: "info" } });
|
||||||
|
|
||||||
|
const first = (await tui.control.next({})).data as {
|
||||||
|
path: string;
|
||||||
|
body: { text?: string };
|
||||||
|
requestID?: string;
|
||||||
|
};
|
||||||
|
const second = (await tui.control.next({})).data as {
|
||||||
|
path: string;
|
||||||
|
body: { command?: string };
|
||||||
|
requestID?: string;
|
||||||
|
};
|
||||||
|
const third = (await tui.control.next({})).data as {
|
||||||
|
path: string;
|
||||||
|
body: { message?: string };
|
||||||
|
requestID?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(first.path).toBe("/tui/append-prompt");
|
||||||
|
expect(first.body.text).toBe("First");
|
||||||
|
expect(first.requestID).toBeDefined();
|
||||||
|
|
||||||
|
expect(second.path).toBe("/tui/execute-command");
|
||||||
|
expect(second.body.command).toBe("prompt.clear");
|
||||||
|
expect(second.requestID).toBeDefined();
|
||||||
|
|
||||||
|
expect(third.path).toBe("/tui/show-toast");
|
||||||
|
expect(third.body.message).toBe("Hello");
|
||||||
|
expect(third.requestID).toBeDefined();
|
||||||
|
|
||||||
|
const empty = (await tui.control.next({})).data as {
|
||||||
|
path: string;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
expect(empty.path).toBe("");
|
||||||
|
expect(empty.body).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles control responses with request IDs", async () => {
|
||||||
|
await tui.appendPrompt({ body: { text: "Ack me" } });
|
||||||
|
const next = (await tui.control.next({})).data as { requestID?: string };
|
||||||
|
expect(next.requestID).toBeDefined();
|
||||||
|
|
||||||
|
const accepted = await tui.control.response({
|
||||||
|
body: { requestID: next.requestID, body: { ok: true } },
|
||||||
|
});
|
||||||
|
expect(accepted.data).toBe(true);
|
||||||
|
|
||||||
|
const duplicate = await tui.control.response({
|
||||||
|
body: { requestID: next.requestID, body: { ok: false } },
|
||||||
|
});
|
||||||
|
expect(duplicate.data).toBe(false);
|
||||||
|
|
||||||
|
const missing = await tui.control.response({
|
||||||
|
body: { requestID: "tui_missing" },
|
||||||
|
});
|
||||||
|
expect(missing.data).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
target
Symbolic link
1
target
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
/home/nathan/sandbox-agent/target
|
||||||
Loading…
Add table
Add a link
Reference in a new issue