tasks: caller-given run names (APPS-153)

NewTaskRequest/TaskRecord/TaskSummary gain an optional name, stored
as-given and omitted when absent so queues fall back to prompt-derived
titles. Automation-triggered runs inherit the automation name; the
tasks tool accepts an optional name param (schema extended, backward
compatible). Purely additive: legacy records decode via serde defaults.

Evidence: new created_tasks_keep_their_given_name passes (create/get/
list round-trip + unnamed omission); task_ filter 181 passed / 0
failed; clippy clean on touched lines.
This commit is contained in:
CodeWhale Bot committed 2026-09-18 00:49:14 -07:00
1 parent 96f993a855
commit 08a4eec009
7 files changed
+85

No files matched your search

+2
View File
@@ -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,
+57
View File
@@ -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();
+18
View File
@@ -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<String>,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
@@ -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<String>,
pub model: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_provider: Option<String>,
@@ -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<String>,
pub model: Option<String>,
#[serde(default)]
pub model_provider: Option<String>,
@@ -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<String>) -> 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,
+5
View File
@@ -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),
+1
View File
@@ -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,
+1
View File
@@ -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()),
+1
View File
@@ -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,