diff --git a/.turbo b/.turbo new file mode 120000 index 0000000..0b7d9ca --- /dev/null +++ b/.turbo @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/.turbo \ No newline at end of file diff --git a/dist b/dist new file mode 120000 index 0000000..f02d77f --- /dev/null +++ b/dist @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/dist \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..501480b --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/node_modules \ No newline at end of file diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 427d440..93357a8 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -460,6 +460,51 @@ struct DirectoryQuery { directory: Option, } +#[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, + message: String, + variant: String, + duration: Option, +} + +#[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, + error: Option, +} + #[derive(Debug, Deserialize, IntoParams)] struct ToolQuery { directory: Option, @@ -4088,29 +4133,79 @@ async fn oc_skill_list() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_next() -> impl IntoResponse { +async fn oc_tui_next( + State(state): State>, + Query(query): Query, + 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": {}}))) } #[utoipa::path( post, path = "/tui/control/response", - request_body = String, + request_body = TuiControlResponseRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_tui_response() -> impl IntoResponse { - bool_ok(true) +async fn oc_tui_response( + State(state): State>, + Query(query): Query, + headers: HeaderMap, + Json(body): Json, +) -> 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( post, path = "/tui/append-prompt", - request_body = String, + request_body = TuiAppendPromptRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_tui_append_prompt() -> impl IntoResponse { +async fn oc_tui_append_prompt( + State(state): State>, + Query(query): Query, + headers: HeaderMap, + Json(body): Json, +) -> 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) } @@ -4120,7 +4215,17 @@ async fn oc_tui_append_prompt() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_help() -> impl IntoResponse { +async fn oc_tui_open_help( + State(state): State>, + Query(query): Query, + 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) } @@ -4130,7 +4235,17 @@ async fn oc_tui_open_help() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_sessions() -> impl IntoResponse { +async fn oc_tui_open_sessions( + State(state): State>, + Query(query): Query, + 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) } @@ -4140,7 +4255,17 @@ async fn oc_tui_open_sessions() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_themes() -> impl IntoResponse { +async fn oc_tui_open_themes( + State(state): State>, + Query(query): Query, + 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) } @@ -4150,18 +4275,37 @@ async fn oc_tui_open_themes() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_open_models() -> impl IntoResponse { +async fn oc_tui_open_models( + State(state): State>, + Query(query): Query, + 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) } #[utoipa::path( post, path = "/tui/submit-prompt", - request_body = String, responses((status = 200)), tag = "opencode" )] -async fn oc_tui_submit_prompt() -> impl IntoResponse { +async fn oc_tui_submit_prompt( + State(state): State>, + Query(query): Query, + 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) } @@ -4171,51 +4315,179 @@ async fn oc_tui_submit_prompt() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tui_clear_prompt() -> impl IntoResponse { +async fn oc_tui_clear_prompt( + State(state): State>, + Query(query): Query, + 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) } #[utoipa::path( post, path = "/tui/execute-command", - request_body = String, + request_body = TuiExecuteCommandRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_tui_execute_command() -> impl IntoResponse { +async fn oc_tui_execute_command( + State(state): State>, + Query(query): Query, + headers: HeaderMap, + Json(body): Json, +) -> 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) } #[utoipa::path( post, path = "/tui/show-toast", - request_body = String, + request_body = TuiShowToastRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_tui_show_toast() -> impl IntoResponse { +async fn oc_tui_show_toast( + State(state): State>, + Query(query): Query, + headers: HeaderMap, + Json(body): Json, +) -> 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) } #[utoipa::path( post, path = "/tui/publish", - request_body = String, + request_body = TuiPublishRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_tui_publish() -> impl IntoResponse { +async fn oc_tui_publish( + State(state): State>, + Query(query): Query, + headers: HeaderMap, + Json(body): Json, +) -> 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) } #[utoipa::path( post, path = "/tui/select-session", - request_body = String, + request_body = TuiSelectSessionRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_tui_select_session() -> impl IntoResponse { +async fn oc_tui_select_session( + State(state): State>, + Query(query): Query, + headers: HeaderMap, + Json(body): Json, +) -> 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) } diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 92460d5..c57fed8 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -50,6 +50,7 @@ use sandbox_agent_agent_management::credentials::{ const MOCK_EVENT_DELAY_MS: u64 = 200; static USER_MESSAGE_COUNTER: AtomicU64 = AtomicU64::new(1); +static TUI_CONTROL_COUNTER: AtomicU64 = AtomicU64::new(1); #[derive(Debug)] pub struct AppState { @@ -360,6 +361,20 @@ struct PendingQuestion { options: Vec, } +#[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, + inflight: HashMap, + responses: HashMap, +} + impl SessionState { fn new( session_id: String, @@ -818,6 +833,7 @@ pub(crate) struct SessionManager { sessions: Mutex>, server_manager: Arc, http_client: Client, + tui_controls: Mutex>, } /// Shared Codex app-server process that handles multiple sessions via JSON-RPC. @@ -1538,6 +1554,7 @@ impl SessionManager { sessions: Mutex::new(Vec::new()), server_manager, http_client: Client::new(), + tui_controls: Mutex::new(HashMap::new()), } } @@ -1960,6 +1977,59 @@ impl SessionManager { 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 { + 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( &self, session_id: &str, diff --git a/server/packages/sandbox-agent/tests/opencode-compat/tui.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/tui.test.ts new file mode 100644 index 0000000..28ed3ed --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/tui.test.ts @@ -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; + }; + 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); + }); +}); diff --git a/target b/target new file mode 120000 index 0000000..3d6ad8c --- /dev/null +++ b/target @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/target \ No newline at end of file