feat(runtime-api): Phase 0 + Phase 1 — brand-neutral naming, capabilities, and dynamic tool protocol types (#3168)

Phase 0:
- Rename runtime API metadata to CodeWhale Runtime API while keeping
  DeepSeek-prefixed env vars and headers as aliases.
- Add CODEWHALE_RUNTIME_TOKEN primary with DEEPSEEK_RUNTIME_TOKEN fallback.
- Accept x-codewhale-runtime-token header alongside x-deepseek-runtime-token.
- Change generated token prefix from dst_ to cwrt_.
- Add runtime_api_version, codewhale_version, transports, capabilities,
  and experimental to /v1/runtime/info while preserving old fields.
- Update CLI help for --auth-token and --cors-origin.
- Add CODEWHALE_CORS_ORIGINS with DEEPSEEK_CORS_ORIGINS alias.

Phase 1:
- Split inline pub mod runtime into crates/protocol/src/runtime/mod.rs.
- Add DynamicToolSpec, DynamicToolItemStatus, DynamicToolCallParams,
  DynamicToolCallResult, DynamicToolCallContent, and TurnEnvironmentParams.
- Accept dynamic_tools and environments on thread create, plus
  dynamic_tools and environment_id on turn start (no-op in Phase 0/1).

Tests:
- Update existing auth/health/runtime_info tests for new naming.
- Add header alias tests and dynamic-tool request deserialization tests.
- Add protocol crate round-trip tests for all new types.

Co-authored-by: CodeWhale Agent <codewhale-agent@hmbown.local>
This commit is contained in:
Hunter Bown
2026-06-12 10:51:54 -07:00
committed by GitHub
parent fc999162c0
commit a448194b81
6 changed files with 572 additions and 47 deletions
+1 -29
View File
@@ -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<String>,
pub item_id: Option<String>,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
pub payload: Value,
#[serde(default)]
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
fn default_runtime_event_envelope_schema_version() -> u32 {
RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
}
}
pub mod runtime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Envelope<T> {
+357
View File
@@ -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<String>,
pub item_id: Option<String>,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub created_at: Option<String>,
pub payload: Value,
#[serde(default)]
#[serde(flatten)]
pub extra: BTreeMap<String, Value>,
}
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
/// `<namespace>::<name>` to the model.
#[serde(skip_serializing_if = "Option::is_none")]
pub namespace: Option<String>,
/// 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<String>,
/// 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<DynamicToolCallContent>,
}
/// 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::<DynamicToolItemStatus>("\"completed\"").unwrap(),
DynamicToolItemStatus::Completed
);
assert_eq!(
serde_json::from_str::<DynamicToolItemStatus>("\"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(&params).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<DynamicToolCallContent> = 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);
}
}
+10 -5
View File
@@ -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<String>,
/// 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<String>,
/// 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<String>
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);
}
+153 -11
View File
@@ -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<String>,
/// 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<String>,
/// 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<String>) -> Option<String> {
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<String> {
async fn health() -> Json<HealthResponse> {
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<RuntimeApiState>) -> Json<RuntimeInfoResponse> {
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(())
+49 -2
View File
@@ -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<String>,
pub workspace: Option<PathBuf>,
@@ -628,6 +629,10 @@ pub struct CreateThreadRequest {
pub system_prompt: Option<String>,
#[serde(default)]
pub task_id: Option<String>,
#[serde(default)]
pub dynamic_tools: Vec<DynamicToolSpec>,
#[serde(default)]
pub environments: Vec<TurnEnvironmentParams>,
}
/// Mutable fields accepted by `PATCH /v1/threads/{id}`.
@@ -648,7 +653,7 @@ pub struct UpdateThreadRequest {
pub workspace: Option<PathBuf>,
}
#[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<bool>,
pub trust_mode: Option<bool>,
pub auto_approve: Option<bool>,
#[serde(default)]
pub dynamic_tools: Vec<DynamicToolSpec>,
#[serde(default)]
pub environment_id: Option<String>,
}
#[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"])?;
+2
View File
@@ -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