diff --git a/crates/tui/src/automation_manager.rs b/crates/tui/src/automation_manager.rs index d9773e3ca..e3afc6679 100644 --- a/crates/tui/src/automation_manager.rs +++ b/crates/tui/src/automation_manager.rs @@ -2046,6 +2046,7 @@ fn new_run_record( fn automation_task_request(automation: &AutomationRecord) -> NewTaskRequest { NewTaskRequest { prompt: automation.prompt.clone(), + name: Some(automation.name.clone()), model: automation.model.clone(), model_provider: automation.model_provider.clone(), model_provider_id: automation.model_provider_id.clone(), @@ -2420,6 +2421,7 @@ where execution_scope: current.execution_scope.clone(), request: NewTaskRequest { prompt: current.message.clone(), + name: None, model: None, model_provider: None, model_provider_id: None, diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index 79193a305..602ee6cd4 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -1585,6 +1585,63 @@ async fn health_and_tasks_endpoints_work() -> Result<()> { Ok(()) } +/// Created tasks keep their caller-given name through get and list; unnamed +/// tasks omit the field so queues can fall back to the prompt summary. +#[tokio::test] +async fn created_tasks_keep_their_given_name() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let named: serde_json::Value = client + .post(format!("http://{addr}/v1/tasks")) + .json(&json!({ "prompt": "migrate the widget", "name": "Widget migration" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(named["name"], "Widget migration"); + let id = named["id"].as_str().expect("task id").to_string(); + + let unnamed: serde_json::Value = client + .post(format!("http://{addr}/v1/tasks")) + .json(&json!({ "prompt": "unnamed work" })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(unnamed.get("name").is_none()); + + let detail: serde_json::Value = client + .get(format!("http://{addr}/v1/tasks/{id}")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(detail["name"], "Widget migration"); + + let listed: serde_json::Value = client + .get(format!("http://{addr}/v1/tasks")) + .send() + .await? + .error_for_status()? + .json() + .await?; + let rows = listed["tasks"].as_array().expect("task rows"); + let row = rows + .iter() + .find(|row| row["id"] == id) + .expect("named row in list"); + assert_eq!(row["name"], "Widget migration"); + + handle.abort(); + Ok(()) +} + #[tokio::test] async fn omitted_runtime_models_use_the_active_provider_default() -> Result<()> { let _env = lock_test_env(); diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index 76e04edae..85e694d6a 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -274,6 +274,8 @@ pub struct TaskRecord { pub schema_version: u32, pub id: String, pub prompt: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub model: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_provider: Option, @@ -339,6 +341,8 @@ pub struct TaskSummary { pub id: String, pub status: TaskStatus, pub prompt_summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, pub model: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub model_provider: Option, @@ -371,6 +375,7 @@ impl From<&TaskRecord> for TaskSummary { id: value.id.clone(), status: value.status, prompt_summary: summarize_text(&value.prompt, TIMELINE_SUMMARY_LIMIT), + name: value.name.clone(), model: value.model.clone(), model_provider: value.model_provider.clone(), model_provider_id: value.model_provider_id.clone(), @@ -406,6 +411,10 @@ pub struct TaskCounts { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewTaskRequest { pub prompt: String, + /// Caller-given run name, stored as-given. Absent names stay absent — + /// titles derived from the prompt are a presentation concern. + #[serde(default)] + pub name: Option, pub model: Option, #[serde(default)] pub model_provider: Option, @@ -424,6 +433,7 @@ impl NewTaskRequest { pub(crate) fn from_task(task: &TaskRecord) -> Self { Self { prompt: task.prompt.clone(), + name: task.name.clone(), model: Some(task.model.clone()), model_provider: task.model_provider.clone(), model_provider_id: task.model_provider_id.clone(), @@ -441,6 +451,7 @@ impl NewTaskRequest { pub fn from_prompt(prompt: impl Into) -> Self { Self { prompt: prompt.into(), + name: None, model: None, model_provider: None, model_provider_id: None, @@ -1669,6 +1680,10 @@ impl TaskManager { // work. id: task_id, prompt, + name: req + .name + .map(|name| name.trim().to_string()) + .filter(|name| !name.is_empty()), model: req.model.unwrap_or_else(|| self.cfg.default_model.clone()), model_provider: req.model_provider, model_provider_id: req.model_provider_id, @@ -4199,6 +4214,7 @@ mod tests { schema_version: CURRENT_TASK_SCHEMA_VERSION, id: task_id.clone(), prompt: "long-running shell work".to_string(), + name: None, model: "deepseek-v4-flash".to_string(), model_provider: None, model_provider_id: None, @@ -4400,6 +4416,7 @@ mod tests { let req = NewTaskRequest { prompt: "fix TODOs and write a README".to_string(), + name: None, model: None, model_provider: None, model_provider_id: None, @@ -4842,6 +4859,7 @@ mod tests { schema_version: CURRENT_TASK_SCHEMA_VERSION, id: "task_0123456789abcdef".to_string(), prompt: "bound timeline".to_string(), + name: None, model: "deepseek-v4-flash".to_string(), model_provider: None, model_provider_id: None, diff --git a/crates/tui/src/tools/tasks.rs b/crates/tui/src/tools/tasks.rs index b0ff09ddc..8d659a7ee 100644 --- a/crates/tui/src/tools/tasks.rs +++ b/crates/tui/src/tools/tasks.rs @@ -224,6 +224,10 @@ impl ToolSpec for TasksTool { "prompt".to_string(), json!({ "type": "string", "description": "Work prompt for the durable task (action=create)." }), ); + properties.insert( + "name".to_string(), + json!({ "type": "string", "description": "Short run name shown in queues; omit to derive from the prompt. (action=create)" }), + ); properties.insert( "model_provider".to_string(), json!({ "type": "string", "description": "Provider kind for the pinned model. Omit to inherit the configured provider." }), @@ -475,6 +479,7 @@ impl TasksTool { let prompt = required_str(input, "prompt")?.to_string(); let req = NewTaskRequest { prompt: prompt.clone(), + name: optional_str(input, "name")?.map(ToString::to_string), model: optional_str(input, "model")?.map(ToString::to_string), model_provider: optional_str(input, "model_provider")?.map(ToString::to_string), model_provider_id: optional_str(input, "model_provider_id")?.map(ToString::to_string), diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index df6c9aac7..4c674814e 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -1088,6 +1088,7 @@ mod tests { id: id.to_string(), status, prompt_summary: "Fix task list output".to_string(), + name: None, model: "deepseek-v4-pro".to_string(), model_provider: None, model_provider_id: None, diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 9ec798888..80d0f691a 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -2318,6 +2318,7 @@ pub(crate) async fn apply_command_result( app.current_session_id = Some(owner_session_id.clone()); let request = NewTaskRequest { prompt: prompt.clone(), + name: None, model: Some(app.model.clone()), model_provider: Some(app.api_provider.as_str().to_string()), model_provider_id: Some(app.provider_identity_for_persistence().to_string()), diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 18b67ca5e..2ff099e41 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -25570,6 +25570,7 @@ mod work_sidebar_projection_tests { id: id.to_string(), status, prompt_summary: format!("task {id}"), + name: None, model: "deepseek-v4-flash".to_string(), model_provider: None, model_provider_id: None,