diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index e6864307..23bcd69a 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -1,37 +1,9 @@ -use std::collections::BTreeMap; use std::path::PathBuf; use serde::{Deserialize, Serialize}; use serde_json::Value; -pub mod runtime { - use super::*; - - pub const RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION: u32 = 1; - - #[derive(Debug, Clone, Serialize, Deserialize)] - pub struct RuntimeEventEnvelope { - #[serde(default = "default_runtime_event_envelope_schema_version")] - pub schema_version: u32, - pub seq: u64, - pub event: String, - pub kind: String, - pub thread_id: String, - pub turn_id: Option, - pub item_id: Option, - pub timestamp: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub created_at: Option, - pub payload: Value, - #[serde(default)] - #[serde(flatten)] - pub extra: BTreeMap, - } - - fn default_runtime_event_envelope_schema_version() -> u32 { - RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION - } -} +pub mod runtime; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Envelope { diff --git a/crates/protocol/src/runtime/mod.rs b/crates/protocol/src/runtime/mod.rs new file mode 100644 index 00000000..1586bb81 --- /dev/null +++ b/crates/protocol/src/runtime/mod.rs @@ -0,0 +1,357 @@ +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION: u32 = 1; +pub const RUNTIME_API_VERSION: &str = "1.0"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeEventEnvelope { + #[serde(default = "default_runtime_event_envelope_schema_version")] + pub schema_version: u32, + pub seq: u64, + pub event: String, + pub kind: String, + pub thread_id: String, + pub turn_id: Option, + pub item_id: Option, + pub timestamp: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + pub payload: Value, + #[serde(default)] + #[serde(flatten)] + pub extra: BTreeMap, +} + +fn default_runtime_event_envelope_schema_version() -> u32 { + RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION +} + +// --------------------------------------------------------------------------- +// Capability advertisement +// --------------------------------------------------------------------------- + +/// Fixed capability map advertised by `GET /v1/runtime/info`. +/// +/// All fields are required on serialization so clients can rely on the shape. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeCapabilities { + pub threads: bool, + pub turns: bool, + pub turn_steer: bool, + pub turn_interrupt: bool, + pub event_replay: bool, + pub external_tools: bool, + pub environments: bool, + pub worker_runtime: bool, +} + +/// Experimental opt-in flags advertised by `GET /v1/runtime/info`. +/// +/// Fields are additive and default to `false` when omitted by older servers. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RuntimeExperimentalCapabilities { + #[serde(default)] + pub environments: bool, +} + +// --------------------------------------------------------------------------- +// External Tool Bridge protocol types +// --------------------------------------------------------------------------- + +/// Specification for a dynamic external tool registered by a runtime client. +/// +/// Example JSON from the spec: +/// +/// ```json +/// { +/// "namespace": "tau_bench", +/// "name": "get_reservation", +/// "description": "Look up an airline reservation.", +/// "input_schema": { +/// "type": "object", +/// "properties": { +/// "reservation_id": { "type": "string" } +/// }, +/// "required": ["reservation_id"], +/// "additionalProperties": false +/// } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DynamicToolSpec { + /// Optional namespace that groups related tools (e.g. `"tau_bench"`). + /// When present, the runtime may expose the tool as + /// `::` to the model. + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + + /// Short tool name. Combined with `namespace` it forms a unique tool id. + pub name: String, + + /// Human-readable description exposed to the model. + pub description: String, + + /// JSON Schema describing the tool's input parameters. + pub input_schema: Value, + + /// If true, the runtime may defer schema validation / tool loading until + /// the model actually calls the tool. + /// + /// Defaults to `false` so that older clients omitting this field still + /// behave the same way. + #[serde(default)] + pub defer_loading: bool, +} + +/// Lifecycle status of a dynamic tool item shown in thread detail and event +/// payloads. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum DynamicToolItemStatus { + InProgress, + Completed, + Failed, +} + +/// Parameters identifying a dynamic tool call request emitted by the runtime. +/// +/// This is the typed payload for `tool_call.requested` events and also the +/// natural identifier used when the runtime looks up a pending call. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DynamicToolCallParams { + pub thread_id: String, + pub turn_id: String, + pub call_id: String, + + /// Optional namespace that was registered with the tool. + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + + /// Tool name that the model invoked. + pub tool: String, + + /// Arguments supplied by the model, validated against `input_schema`. + pub arguments: Value, +} + +/// Result submitted by a runtime client after executing a dynamic tool. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DynamicToolCallResult { + /// Whether the client-side tool execution succeeded. + pub success: bool, + + /// Content fragments returned by the tool. + /// + /// Defaults to an empty vector when omitted so clients can send a minimal + /// `{ "success": false }` payload. + #[serde(default)] + pub content: Vec, +} + +/// A single content fragment inside a [`DynamicToolCallResult`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum DynamicToolCallContent { + InputText { text: String }, + InputImage { image_url: String }, +} + +// --------------------------------------------------------------------------- +// Environment targeting protocol types +// --------------------------------------------------------------------------- + +/// Environment target selected for a turn's shell/filesystem work. +/// +/// Example JSON: +/// +/// ```json +/// { +/// "environment_id": "local", +/// "cwd": "/workspace" +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: PathBuf, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn dynamic_tool_spec_roundtrip() { + let spec = DynamicToolSpec { + namespace: Some("tau_bench".into()), + name: "get_reservation".into(), + description: "Look up an airline reservation.".into(), + input_schema: json!({ + "type": "object", + "properties": { + "reservation_id": { "type": "string" } + }, + "required": ["reservation_id"], + "additionalProperties": false + }), + defer_loading: false, + }; + + let serialized = serde_json::to_string(&spec).unwrap(); + let deserialized: DynamicToolSpec = serde_json::from_str(&serialized).unwrap(); + assert_eq!(spec, deserialized); + } + + #[test] + fn dynamic_tool_spec_omits_defer_loading_defaults_false() { + let json = r#"{ + "namespace": "tau_bench", + "name": "get_reservation", + "description": "Look up an airline reservation.", + "input_schema": { "type": "object" } + }"#; + + let spec: DynamicToolSpec = serde_json::from_str(json).unwrap(); + assert_eq!(spec.namespace, Some("tau_bench".into())); + assert_eq!(spec.name, "get_reservation"); + assert!(!spec.defer_loading); + } + + #[test] + fn dynamic_tool_item_status_snake_case() { + assert_eq!( + serde_json::to_string(&DynamicToolItemStatus::InProgress).unwrap(), + "\"in_progress\"" + ); + assert_eq!( + serde_json::from_str::("\"completed\"").unwrap(), + DynamicToolItemStatus::Completed + ); + assert_eq!( + serde_json::from_str::("\"failed\"").unwrap(), + DynamicToolItemStatus::Failed + ); + } + + #[test] + fn dynamic_tool_call_params_roundtrip() { + let params = DynamicToolCallParams { + thread_id: "thr_123".into(), + turn_id: "turn_456".into(), + call_id: "call_abc".into(), + namespace: Some("tau_bench".into()), + tool: "get_reservation".into(), + arguments: json!({ "reservation_id": "ABC123" }), + }; + + let serialized = serde_json::to_string(¶ms).unwrap(); + let deserialized: DynamicToolCallParams = serde_json::from_str(&serialized).unwrap(); + assert_eq!(params, deserialized); + } + + #[test] + fn dynamic_tool_call_content_roundtrip() { + let content = vec![ + DynamicToolCallContent::InputText { + text: "{\"status\":\"confirmed\"}".into(), + }, + DynamicToolCallContent::InputImage { + image_url: "http://example.com/receipt.png".into(), + }, + ]; + + let value = serde_json::to_value(&content).unwrap(); + let deserialized: Vec = serde_json::from_value(value).unwrap(); + assert_eq!(content, deserialized); + + // Verify the exact JSON tag names expected by the spec. + assert_eq!( + serde_json::to_string(&DynamicToolCallContent::InputText { text: "x".into() }).unwrap(), + r#"{"type":"input_text","text":"x"}"# + ); + assert_eq!( + serde_json::to_string(&DynamicToolCallContent::InputImage { image_url: "y".into() }) + .unwrap(), + r#"{"type":"input_image","image_url":"y"}"# + ); + } + + #[test] + fn dynamic_tool_call_result_defaults_empty_content() { + let json = r#"{ "success": false }"#; + let result: DynamicToolCallResult = serde_json::from_str(json).unwrap(); + assert!(!result.success); + assert!(result.content.is_empty()); + } + + #[test] + fn dynamic_tool_call_result_roundtrip_with_content() { + let result = DynamicToolCallResult { + success: true, + content: vec![DynamicToolCallContent::InputText { text: "done".into() }], + }; + + let serialized = serde_json::to_string(&result).unwrap(); + let deserialized: DynamicToolCallResult = serde_json::from_str(&serialized).unwrap(); + assert_eq!(result, deserialized); + } + + #[test] + fn turn_environment_params_roundtrip() { + let env = TurnEnvironmentParams { + environment_id: "local".into(), + cwd: PathBuf::from("/workspace"), + }; + + let serialized = serde_json::to_string(&env).unwrap(); + let deserialized: TurnEnvironmentParams = serde_json::from_str(&serialized).unwrap(); + assert_eq!(env, deserialized); + + // Verify JSON from the spec deserializes directly. + let from_spec = r#"{ + "environment_id": "local", + "cwd": "/workspace" + }"#; + let parsed: TurnEnvironmentParams = serde_json::from_str(from_spec).unwrap(); + assert_eq!(parsed.environment_id, "local"); + assert_eq!(parsed.cwd, PathBuf::from("/workspace")); + } + + #[test] + fn runtime_capabilities_serializes_expected_shape() { + let caps = RuntimeCapabilities { + threads: true, + turns: true, + turn_steer: true, + turn_interrupt: true, + event_replay: true, + external_tools: false, + environments: false, + worker_runtime: false, + }; + let value = serde_json::to_value(&caps).unwrap(); + let obj = value.as_object().unwrap(); + assert_eq!(obj.get("threads").unwrap(), &json!(true)); + assert_eq!(obj.get("external_tools").unwrap(), &json!(false)); + assert!(obj.contains_key("worker_runtime")); + } + + #[test] + fn runtime_event_envelope_schema_version_default() { + let json = r#"{ + "seq": 1, + "event": "test", + "kind": "test", + "thread_id": "thr_1", + "timestamp": "2026-06-12T00:00:00Z", + "payload": {} + }"#; + let envelope: RuntimeEventEnvelope = serde_json::from_str(json).unwrap(); + assert_eq!(envelope.schema_version, RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION); + } +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 399ccc76..c748e693 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -696,12 +696,14 @@ struct ServeArgs { workers: usize, /// Additional CORS origin to allow (repeatable). Stacks on top of the /// built-in defaults (localhost:3000, localhost:1420, tauri://localhost). - /// Also reads `DEEPSEEK_CORS_ORIGINS` (comma-separated) and - /// `[runtime_api] cors_origins` from `config.toml`. Whalescale#255. + /// Also reads `CODEWHALE_CORS_ORIGINS` (comma-separated), then + /// `DEEPSEEK_CORS_ORIGINS` as an alias, and `[runtime_api] cors_origins` + /// from `config.toml`. Whalescale#255. #[arg(long = "cors-origin", value_name = "URL")] cors_origin: Vec, /// Require this bearer token for `/v1/*` runtime API routes. Also reads - /// `DEEPSEEK_RUNTIME_TOKEN` when omitted. + /// `CODEWHALE_RUNTIME_TOKEN` when omitted, then `DEEPSEEK_RUNTIME_TOKEN` + /// as an alias. #[arg(long = "auth-token", value_name = "TOKEN")] auth_token: Option, /// Disable runtime API auth when no token is configured. Only use on a trusted loopback. @@ -1740,7 +1742,8 @@ fn init_plugins_dir( /// /// Sources, in priority order (later sources extend earlier ones): /// 1. `--cors-origin URL` flags (repeatable) -/// 2. `DEEPSEEK_CORS_ORIGINS` env var (comma-separated) +/// 2. `CODEWHALE_CORS_ORIGINS` env var (comma-separated), +/// then `DEEPSEEK_CORS_ORIGINS` as an alias /// 3. `[runtime_api] cors_origins = [...]` in `config.toml` /// /// The runtime API always allows the built-in dev defaults @@ -1761,7 +1764,9 @@ fn resolve_cors_origins(config: &Config, flag_origins: &[String]) -> Vec for o in flag_origins { push(o); } - if let Ok(env_value) = std::env::var("DEEPSEEK_CORS_ORIGINS") { + if let Ok(env_value) = std::env::var("CODEWHALE_CORS_ORIGINS") + .or_else(|_| std::env::var("DEEPSEEK_CORS_ORIGINS")) + { for piece in env_value.split(',') { push(piece); } diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index d8664544..54ba1040 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -1,4 +1,4 @@ -//! Runtime HTTP/SSE API for local DeepSeek automation. +//! Runtime HTTP/SSE API for local CodeWhale automation. use std::collections::{HashMap, HashSet}; use std::convert::Infallible; @@ -19,7 +19,10 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; use chrono::Utc; -use codewhale_protocol::runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope}; +use codewhale_protocol::runtime::{ + RUNTIME_API_VERSION, RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeCapabilities, + RuntimeEventEnvelope, RuntimeExperimentalCapabilities, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use tokio::net::TcpListener; @@ -77,11 +80,13 @@ pub struct RuntimeApiOptions { /// Additional CORS origins to allow on top of the built-in defaults /// (`http://localhost:{3000,1420}`, `http://127.0.0.1:{3000,1420}`, /// `tauri://localhost`). Populated by `--cors-origin` (repeatable), - /// `DEEPSEEK_CORS_ORIGINS` (comma-separated), and `[runtime_api] - /// cors_origins` in `config.toml`. Whalescale#255 / #561. + /// `CODEWHALE_CORS_ORIGINS` (comma-separated, `DEEPSEEK_CORS_ORIGINS` + /// as alias), and `[runtime_api] cors_origins` in `config.toml`. + /// Whalescale#255 / #561. pub cors_origins: Vec, /// Optional bearer token required for `/v1/*` routes. If omitted here, - /// `run_http_server` also checks `DEEPSEEK_RUNTIME_TOKEN`. + /// `run_http_server` checks `CODEWHALE_RUNTIME_TOKEN`, then + /// `DEEPSEEK_RUNTIME_TOKEN` as an alias. pub auth_token: Option, /// Allow `/v1/*` routes without auth when no token is configured. pub insecure_no_auth: bool, @@ -144,7 +149,7 @@ fn first_nonblank_token(token: Option) -> Option { fn generate_runtime_token() -> String { format!( - "dst_{}{}", + "cwrt_{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple() ) @@ -359,12 +364,32 @@ struct SubmitUserInputResponse { #[derive(Debug, Serialize)] struct RuntimeInfoResponse { + service: &'static str, + runtime_api_version: &'static str, + codewhale_version: &'static str, bind_host: String, port: u16, auth_required: bool, + transports: Vec<&'static str>, + capabilities: RuntimeCapabilities, + experimental: RuntimeExperimentalCapabilities, + // Backward-compatible alias kept for existing clients. version: &'static str, } +fn default_runtime_capabilities() -> RuntimeCapabilities { + RuntimeCapabilities { + threads: true, + turns: true, + turn_steer: true, + turn_interrupt: true, + event_replay: true, + external_tools: false, + environments: false, + worker_runtime: false, + } +} + #[derive(Debug, Serialize)] struct McpServerEntry { name: String, @@ -456,9 +481,12 @@ pub async fn run_http_server( .map(|h| h.join(".deepseek").join("sessions")) .unwrap_or_else(|| PathBuf::from(".deepseek").join("sessions")) }); + let runtime_token_env = std::env::var("CODEWHALE_RUNTIME_TOKEN") + .ok() + .or_else(|| std::env::var("DEEPSEEK_RUNTIME_TOKEN").ok()); let resolved_auth = resolve_runtime_auth( options.auth_token.clone(), - std::env::var("DEEPSEEK_RUNTIME_TOKEN").ok(), + runtime_token_env, options.insecure_no_auth, ); let runtime_token = resolved_auth.token.clone(); @@ -500,7 +528,7 @@ pub async fn run_http_server( if let Some(token) = runtime_token.as_deref() { println!("Runtime API auth: generated bearer token for this process."); println!(" Authorization: Bearer {token}"); - println!(" Set DEEPSEEK_RUNTIME_TOKEN or pass --auth-token for a stable token."); + println!(" Set CODEWHALE_RUNTIME_TOKEN (or DEEPSEEK_RUNTIME_TOKEN as an alias) or pass --auth-token for a stable token."); } } else if auth_enabled { println!("Runtime API auth: bearer token required for /v1/* routes."); @@ -638,6 +666,11 @@ fn request_has_runtime_token(req: &Request, expected: &str) -> bool { .and_then(|value| value.to_str().ok()) .and_then(|raw| raw.strip_prefix("Bearer ")) .is_some_and(|token| token == expected) + || req + .headers() + .get("x-codewhale-runtime-token") + .and_then(|value| value.to_str().ok()) + .is_some_and(|token| token == expected) || req .headers() .get("x-deepseek-runtime-token") @@ -784,7 +817,7 @@ fn detect_lan_ip() -> Option { async fn health() -> Json { Json(HealthResponse { status: "ok", - service: "deepseek-runtime-api", + service: "codewhale-runtime-api", mode: "local", }) } @@ -853,6 +886,7 @@ async fn resume_session_thread( archived: false, system_prompt: session.system_prompt.clone(), task_id: None, + ..Default::default() }) .await .map_err(|e| ApiError::internal(format!("Failed to create thread: {e}")))?; @@ -1396,11 +1430,18 @@ async fn submit_user_input( } async fn runtime_info(State(state): State) -> Json { + let version = env!("CARGO_PKG_VERSION"); Json(RuntimeInfoResponse { + service: "codewhale-runtime-api", + runtime_api_version: RUNTIME_API_VERSION, + codewhale_version: version, bind_host: state.bind_host.clone(), port: state.bind_port, auth_required: state.auth_required, - version: env!("CARGO_PKG_VERSION"), + transports: vec!["http", "sse"], + capabilities: default_runtime_capabilities(), + experimental: RuntimeExperimentalCapabilities::default(), + version, }) } @@ -1800,6 +1841,7 @@ async fn stream_turn( archived: true, system_prompt: None, task_id: None, + ..Default::default() }) .await .map_err(|e| ApiError::internal(format!("Failed to create stream thread: {e}")))?; @@ -1816,6 +1858,7 @@ async fn stream_turn( allow_shell: Some(allow_shell), trust_mode: Some(trust_mode), auto_approve: Some(auto_approve), + ..Default::default() }, ) .await @@ -2587,7 +2630,7 @@ mod tests { let auth = resolve_runtime_auth(None, None, false); assert!(auth.generated); let token = auth.token.expect("generated token"); - assert!(token.starts_with("dst_")); + assert!(token.starts_with("cwrt_")); assert!(token.len() > 32); } @@ -2891,6 +2934,7 @@ mod tests { .json() .await?; assert_eq!(health["status"], "ok"); + assert_eq!(health["service"], "codewhale-runtime-api"); let created: serde_json::Value = client .post(format!("http://{addr}/v1/tasks")) @@ -2976,6 +3020,22 @@ mod tests { .error_for_status()?; assert_eq!(query_token.status(), StatusCode::OK); + let codewhale_header = client + .get(format!("http://{addr}/v1/threads/summary")) + .header("x-codewhale-runtime-token", &token) + .send() + .await? + .error_for_status()?; + assert_eq!(codewhale_header.status(), StatusCode::OK); + + let deepseek_header = client + .get(format!("http://{addr}/v1/threads/summary")) + .header("x-deepseek-runtime-token", &token) + .send() + .await? + .error_for_status()?; + assert_eq!(deepseek_header.status(), StatusCode::OK); + handle.abort(); Ok(()) } @@ -4753,9 +4813,91 @@ mod tests { .error_for_status()? .json() .await?; + assert_eq!(info["service"], "codewhale-runtime-api"); + assert_eq!(info["runtime_api_version"], "1.0"); + assert_eq!(info["codewhale_version"], info["version"]); assert_eq!(info["bind_host"], "127.0.0.1"); assert_eq!(info["auth_required"], false); assert!(info["version"].is_string()); + assert_eq!(info["transports"], json!(["http", "sse"])); + assert_eq!(info["capabilities"]["threads"], true); + assert_eq!(info["capabilities"]["external_tools"], false); + assert!(info["experimental"].is_object()); + + handle.abort(); + Ok(()) + } + + #[tokio::test] + async fn create_thread_accepts_dynamic_tools_and_environments() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ + "model": "test-model", + "dynamic_tools": [ + { + "namespace": "tau_bench", + "name": "get_reservation", + "description": "Look up a reservation.", + "input_schema": { "type": "object" } + } + ], + "environments": [ + { "environment_id": "local", "cwd": "/workspace" } + ] + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(created["id"].is_string()); + + handle.abort(); + Ok(()) + } + + #[tokio::test] + async fn start_turn_accepts_dynamic_tools_and_environment_id() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let created: serde_json::Value = client + .post(format!("http://{addr}/v1/threads")) + .json(&json!({ "model": "test-model" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let thread_id = created["id"].as_str().context("missing thread id")?; + + let started: serde_json::Value = client + .post(format!("http://{addr}/v1/threads/{thread_id}/turns")) + .json(&json!({ + "prompt": "hello", + "dynamic_tools": [ + { + "name": "simple_tool", + "description": "A simple tool.", + "input_schema": { "type": "object" } + } + ], + "environment_id": "local" + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(started["turn"]["thread_id"], thread_id); handle.abort(); Ok(()) diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index d5f2db3f..75de6a9c 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -39,6 +39,7 @@ use crate::tools::plan::new_shared_plan_state; use crate::tools::subagent::SubAgentStatus; use crate::tools::todo::new_shared_todo_list; use crate::tui::app::AppMode; +use codewhale_protocol::runtime::{DynamicToolSpec, TurnEnvironmentParams}; const EVENT_CHANNEL_CAPACITY: usize = 1024; const MAX_ACTIVE_THREADS_DEFAULT: usize = 8; @@ -614,7 +615,7 @@ pub enum ThreadListFilter { ArchivedOnly, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct CreateThreadRequest { pub model: Option, pub workspace: Option, @@ -628,6 +629,10 @@ pub struct CreateThreadRequest { pub system_prompt: Option, #[serde(default)] pub task_id: Option, + #[serde(default)] + pub dynamic_tools: Vec, + #[serde(default)] + pub environments: Vec, } /// Mutable fields accepted by `PATCH /v1/threads/{id}`. @@ -648,7 +653,7 @@ pub struct UpdateThreadRequest { pub workspace: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct StartTurnRequest { pub prompt: String, #[serde(default)] @@ -658,6 +663,10 @@ pub struct StartTurnRequest { pub allow_shell: Option, pub trust_mode: Option, pub auto_approve: Option, + #[serde(default)] + pub dynamic_tools: Vec, + #[serde(default)] + pub environment_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -3776,6 +3785,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -3836,6 +3846,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -3882,6 +3893,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -3906,6 +3918,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -3972,6 +3985,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4005,6 +4019,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4067,6 +4082,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4084,6 +4100,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: Some(true), + ..Default::default() }, ) .await?; @@ -4110,6 +4127,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4127,6 +4145,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: Some(false), + ..Default::default() }, ) .await?; @@ -4153,6 +4172,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4186,6 +4206,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4236,6 +4257,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4296,6 +4318,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -4313,6 +4336,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -4360,6 +4384,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4435,6 +4460,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4475,6 +4501,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -4527,6 +4554,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4542,6 +4570,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: Some(true), + ..Default::default() }, ) .await?; @@ -4613,6 +4642,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4628,6 +4658,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -4708,6 +4739,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4723,6 +4755,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -4788,6 +4821,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; let mut harness = install_mock_engine(&manager, &thread.id).await; @@ -4803,6 +4837,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: Some(true), + ..Default::default() }, ) .await?; @@ -4899,6 +4934,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; assert!(!manager.store.load_thread(&thread.id)?.auto_approve); @@ -4915,6 +4951,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -4983,6 +5020,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -4998,6 +5036,7 @@ mod tests { allow_shell: None, trust_mode: Some(true), auto_approve: Some(true), + ..Default::default() }, ) .await?; @@ -5068,6 +5107,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -5125,6 +5165,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -5177,6 +5218,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; @@ -5275,6 +5317,7 @@ mod tests { allow_shell: None, trust_mode: None, auto_approve: None, + ..Default::default() }, ) .await?; @@ -5691,6 +5734,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; seed_turns_with_user_messages(&manager, &thread.id, &["first", "second", "third"])?; @@ -5727,6 +5771,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; seed_turns_with_user_messages(&manager, &thread.id, &["a", "b", "c", "d"])?; @@ -5756,6 +5801,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; seed_turns_with_user_messages(&manager, &thread.id, &["only"])?; @@ -5782,6 +5828,7 @@ mod tests { archived: false, system_prompt: None, task_id: None, + ..Default::default() }) .await?; let turn_ids = seed_turns_with_user_messages(&manager, &thread.id, &["x", "y", "z"])?; diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index 95db71f8..c2feca24 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -441,6 +441,7 @@ impl TaskExecutor for EngineTaskExecutor { archived: false, system_prompt: None, task_id: Some(task.id.clone()), + ..Default::default() }) .await { @@ -466,6 +467,7 @@ impl TaskExecutor for EngineTaskExecutor { allow_shell: Some(task.allow_shell), trust_mode: Some(task.trust_mode), auto_approve: Some(task.auto_approve), + ..Default::default() }, ) .await