Track task progress and organize Galaxy Drive plans

This commit is contained in:
Ryan Ward
2026-10-20 21:57:15 +00:00
parent cb830e1ba7
commit 7ccb0e9abc
30 changed files with 2791 additions and 105 deletions
+2
View File
@@ -6,10 +6,12 @@
mod provider_run;
mod runtime;
mod task_list;
mod tool_policy;
mod types;
pub use provider_run::*;
pub use runtime::*;
pub use task_list::*;
pub use tool_policy::*;
pub use types::*;
+58 -2
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use crate::{
AgentError, ContentPart, ConversationMessage, MessageContent, MessageRole, PermissionDecision,
PermissionRequest, StopReason, ToolCall, ToolCallDecision, ToolPolicy, ToolResult,
ToolResultStatus, Usage,
PermissionRequest, StopReason, TaskListSnapshot, ToolCall, ToolCallDecision, ToolPolicy,
ToolResult, ToolResultStatus, UPDATE_TASK_LIST_NAME, Usage, task_list_from_tool_result,
};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -357,6 +357,9 @@ pub enum ProviderRunProtocolError {
InvalidTranscriptCompaction {
message: String,
},
InvalidTaskList {
message: String,
},
InvalidRestoredState {
message: String,
},
@@ -422,6 +425,7 @@ impl fmt::Display for ProviderRunProtocolError {
Self::InvalidTranscriptCompaction { message } => {
write!(f, "invalid transcript compaction: {message}")
}
Self::InvalidTaskList { message } => write!(f, "invalid task list: {message}"),
Self::InvalidRestoredState { message } => {
write!(f, "invalid restored provider run: {message}")
}
@@ -439,6 +443,8 @@ pub struct ProviderRun {
epoch: RunEpoch,
transcript: Vec<ConversationMessage>,
tool_result_archive: Vec<ConversationMessage>,
#[serde(default)]
task_list: Option<TaskListSnapshot>,
usage: Usage,
model_turns: u32,
model_retries: u32,
@@ -460,6 +466,7 @@ impl ProviderRun {
epoch: RunEpoch::default(),
transcript,
tool_result_archive: Vec::new(),
task_list: None,
usage: Usage::default(),
model_turns: 0,
model_retries: 0,
@@ -489,6 +496,23 @@ impl ProviderRun {
&self.transcript
}
pub fn task_list(&self) -> Option<&TaskListSnapshot> {
self.task_list.as_ref()
}
pub fn set_task_list(
&mut self,
task_list: Option<TaskListSnapshot>,
) -> Result<(), ProviderRunProtocolError> {
if let Some(snapshot) = &task_list {
snapshot
.validate()
.map_err(|message| ProviderRunProtocolError::InvalidTaskList { message })?;
}
self.task_list = task_list;
Ok(())
}
pub fn tool_result_archive(&self) -> &[ConversationMessage] {
&self.tool_result_archive
}
@@ -564,6 +588,11 @@ impl ProviderRun {
if self.profile.as_str().is_empty() {
return Err(invalid("request profile must not be empty".to_string()));
}
if let Some(snapshot) = &self.task_list {
snapshot
.validate()
.map_err(|error| invalid(format!("invalid task list: {error}")))?;
}
if self.limits.max_model_turns == Some(0) {
return Err(invalid("model-turn limit must be at least one".to_string()));
}
@@ -1273,6 +1302,20 @@ impl ProviderRun {
});
};
let next_epoch = self.next_epoch()?;
let mut task_list = self.task_list.clone();
for pending in &batch.calls {
if pending.call.name != UPDATE_TASK_LIST_NAME {
continue;
}
let result = pending.state.result().expect("validated complete tool batch");
if result.status == ToolResultStatus::Success {
task_list = Some(task_list_from_tool_result(&result.content).ok_or_else(|| {
ProviderRunProtocolError::InvalidTaskList {
message: "successful task update has no valid snapshot result".to_string(),
}
})?);
}
}
let yielded_to_children = batch.calls.iter().any(|pending| {
is_local_agent_launch(&pending.call)
&& pending
@@ -1281,6 +1324,7 @@ impl ProviderRun {
.is_some_and(|result| result.status == ToolResultStatus::Success)
});
self.transcript.push(tool_result_message(results));
self.task_list = task_list;
self.epoch = next_epoch;
// Launch acknowledgement is a yield, not an invitation to issue more tools.
// The client persists child membership and supplies a new input once the group finishes.
@@ -1634,6 +1678,18 @@ fn validate_pending_tool_state(pending: &PendingToolCall) -> Result<(), String>
| PendingToolCallState::RecoveryPending
| PendingToolCallState::Resolved { .. } => {}
}
if pending.call.name == UPDATE_TASK_LIST_NAME {
if let Some(result) = pending.state.result() {
if result.status == ToolResultStatus::Success {
let snapshot = task_list_from_tool_result(&result.content)
.ok_or_else(|| "successful task update has no valid snapshot result".to_string())?;
let expected = TaskListSnapshot::from_arguments(&pending.call.arguments)?;
if snapshot != expected {
return Err("task update result does not match its arguments".to_string());
}
}
}
}
Ok(())
}
@@ -1,7 +1,7 @@
use serde_json::json;
use super::*;
use crate::{AgentErrorKind, PermissionKind};
use crate::{AgentErrorKind, PermissionKind, TaskListItem, TaskListStatus};
fn initial_messages() -> Vec<ConversationMessage> {
vec![ConversationMessage {
@@ -354,6 +354,201 @@ fn unknown_malformed_and_inline_tools_are_pre_resolved_in_the_same_batch() {
assert_eq!(batch.unresolved_call_ids(), vec!["external"]);
}
fn task_snapshot(status: TaskListStatus) -> TaskListSnapshot {
TaskListSnapshot {
tasks: vec![TaskListItem {
id: "fix".to_string(),
title: "Fix the failure".to_string(),
status,
}],
}
}
fn update_task_call(id: &str, snapshot: &TaskListSnapshot) -> ToolCall {
ToolCall {
id: id.to_string(),
name: UPDATE_TASK_LIST_NAME.to_string(),
arguments: serde_json::to_value(snapshot).unwrap(),
}
}
#[test]
fn inline_task_updates_commit_atomically_in_call_order_with_other_tools() {
let mut run = run();
let initial = task_snapshot(TaskListStatus::Pending);
let working = task_snapshot(TaskListStatus::InProgress);
let completed = task_snapshot(TaskListStatus::Completed);
run.set_task_list(Some(initial.clone())).unwrap();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![
update_task_call("tasks-start", &working),
tool_call("external", "read_files"),
update_task_call("tasks-finish", &completed),
],
&[UPDATE_TASK_LIST_NAME, "read_files"],
),
);
assert_eq!(batch.unresolved_call_ids(), vec!["external"]);
assert_eq!(run.task_list(), Some(&initial));
assert!(matches!(
run.commit_tool_batch(&batch.work_id),
Err(ProviderRunProtocolError::IncompleteToolBatch { .. }),
));
assert_eq!(run.task_list(), Some(&initial));
run.complete_tool(&batch.work_id, successful_result("external", "contents"))
.unwrap();
run.commit_tool_batch(&batch.work_id).unwrap();
assert_eq!(run.task_list(), Some(&completed));
assert_eq!(run.state().phase(), ProviderRunPhase::ReadyToCallModel);
assert_serialization_round_trip(&run);
let committed = run.clone();
assert!(run.commit_tool_batch(&batch.work_id).is_err());
assert_eq!(run, committed);
}
#[test]
fn rejected_task_updates_do_not_replace_durable_progress() {
let mut run = run();
let initial = task_snapshot(TaskListStatus::InProgress);
run.set_task_list(Some(initial.clone())).unwrap();
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![ToolCall {
id: "invalid-task-update".to_string(),
name: UPDATE_TASK_LIST_NAME.to_string(),
arguments: json!({"tasks": []}),
}],
&[UPDATE_TASK_LIST_NAME],
),
);
assert_eq!(
batch.calls[0].state.result().unwrap().status,
ToolResultStatus::Error,
);
run.commit_tool_batch(&batch.work_id).unwrap();
assert_eq!(run.task_list(), Some(&initial));
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![update_task_call(
"unadvertised-update",
&task_snapshot(TaskListStatus::Completed),
)],
&[],
),
);
run.commit_tool_batch(&batch.work_id).unwrap();
assert_eq!(run.task_list(), Some(&initial));
}
#[test]
fn task_progress_survives_compaction_archive_eviction_and_restore() {
let mut run = run();
let snapshot = task_snapshot(TaskListStatus::InProgress);
let batch = accept_tool_turn(
&mut run,
tool_turn(
vec![update_task_call("tasks", &snapshot)],
&[UPDATE_TASK_LIST_NAME],
),
);
assert!(batch.is_complete());
run.commit_tool_batch(&batch.work_id).unwrap();
run.compact_transcript_at_model_boundary(
run.transcript().len(),
vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Earlier conversation summary".to_string()),
}],
)
.unwrap();
let mut archive = run.tool_result_archive().to_vec();
for index in 0..450 {
archive.extend([
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
tool_use_id: format!("read-{index}"),
name: "read_files".to_string(),
input: json!({}),
},
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::ToolResult {
tool_use_id: format!("read-{index}"),
content: "contents".to_string(),
is_error: false,
},
},
]);
}
run.replace_tool_result_archive(archive);
assert!(!serde_json::to_string(run.tool_result_archive())
.unwrap()
.contains("task_list_snapshot"));
assert_eq!(run.task_list(), Some(&snapshot));
assert_serialization_round_trip(&run);
run.validate_restored_state().unwrap();
}
#[test]
fn task_list_setter_and_restored_snapshots_validate_before_mutation() {
let mut run = run();
let initial = task_snapshot(TaskListStatus::InProgress);
run.set_task_list(Some(initial.clone())).unwrap();
assert!(matches!(
run.set_task_list(Some(TaskListSnapshot { tasks: Vec::new() })),
Err(ProviderRunProtocolError::InvalidTaskList { .. }),
));
assert_eq!(run.task_list(), Some(&initial));
let invalid = mutate_run_json(&run, |value| value["task_list"] = json!({"tasks": []}));
assert!(restored_state_error(&invalid).contains("invalid task list"));
let legacy = mutate_run_json(&run, |value| {
value.as_object_mut().unwrap().remove("task_list");
});
assert_eq!(legacy.task_list(), None);
legacy.validate_restored_state().unwrap();
run.set_task_list(None).unwrap();
assert_eq!(run.task_list(), None);
}
#[test]
fn restoring_a_completed_inline_task_batch_commits_once_without_external_work() {
let mut run = run();
let snapshot = task_snapshot(TaskListStatus::InProgress);
accept_tool_turn(
&mut run,
tool_turn(
vec![update_task_call("tasks", &snapshot)],
&[UPDATE_TASK_LIST_NAME],
),
);
let mut restored: ProviderRun = serde_json::from_str(&serde_json::to_string(&run).unwrap()).unwrap();
restored.validate_restored_state().unwrap();
assert_eq!(restored.task_list(), None);
assert!(restored.normalize_after_restore().unwrap().committed_tool_batch);
assert_eq!(restored.task_list(), Some(&snapshot));
assert!(!restored.normalize_after_restore().unwrap().committed_tool_batch);
assert_eq!(restored.task_list(), Some(&snapshot));
let corrupted = mutate_run_json(&run, |value| {
value["state"]["AwaitingTools"]["batch"]["calls"][0]["state"]["Resolved"]["result"]["content"] =
json!(task_snapshot(TaskListStatus::Completed).to_tool_result_content());
});
assert!(restored_state_error(&corrupted).contains("does not match its arguments"));
}
#[test]
fn parallel_tool_results_commit_atomically_in_original_call_order() {
let mut run = run();
+129
View File
@@ -0,0 +1,129 @@
use std::collections::HashSet;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
pub const UPDATE_TASK_LIST_NAME: &str = "update_task_list";
pub const MAX_TASK_LIST_ITEMS: usize = 50;
pub const MAX_TASK_ID_CHARS: usize = 200;
pub const MAX_TASK_TITLE_CHARS: usize = 1_000;
/// A complete replacement of the task list. Callers retain task IDs across updates.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskListSnapshot {
pub tasks: Vec<TaskListItem>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TaskListItem {
pub id: String,
pub title: String,
pub status: TaskListStatus,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TaskListStatus {
Pending,
InProgress,
Completed,
}
impl TaskListSnapshot {
pub fn from_arguments(arguments: &JsonValue) -> Result<Self, String> {
let mut arguments = arguments.clone();
if let Some(fields) = arguments.as_object_mut() {
if let Some(title) = fields.remove("_galaxy_tool_title") {
if !title.is_string() {
return Err("The reserved tool title must be a string.".to_string());
}
}
}
let snapshot: Self = serde_json::from_value(arguments)
.map_err(|error| format!("Invalid task list arguments: {error}"))?;
snapshot.validate()?;
Ok(snapshot)
}
pub fn validate(&self) -> Result<(), String> {
if self.tasks.is_empty() || self.tasks.len() > MAX_TASK_LIST_ITEMS {
return Err(format!(
"Task lists must contain between 1 and {MAX_TASK_LIST_ITEMS} tasks."
));
}
let mut ids = HashSet::new();
let mut has_in_progress = false;
let mut has_pending = false;
for task in &self.tasks {
if task.id.trim().is_empty() || task.id.chars().count() > MAX_TASK_ID_CHARS {
return Err(format!(
"Task IDs must be nonempty and at most {MAX_TASK_ID_CHARS} characters."
));
}
if !ids.insert(task.id.as_str()) {
return Err(format!("Duplicate task ID: '{}'.", task.id));
}
if task.title.trim().is_empty() || task.title.chars().count() > MAX_TASK_TITLE_CHARS
{
return Err(format!(
"Task titles must be nonempty and at most {MAX_TASK_TITLE_CHARS} characters."
));
}
match task.status {
TaskListStatus::Pending => has_pending = true,
TaskListStatus::InProgress => {
if has_in_progress {
return Err("Only one task may be in progress at a time.".to_string());
}
if has_pending {
return Err(
"The task in progress must precede all pending tasks; reorder the task list to reflect the current work."
.to_string(),
);
}
has_in_progress = true;
}
TaskListStatus::Completed => {}
}
}
Ok(())
}
pub fn to_tool_result_content(&self) -> String {
serde_json::to_string(&TaskListToolResult {
kind: TaskListResultKind::Snapshot,
tasks: self.tasks.clone(),
})
.expect("task list snapshots contain only serializable strings and statuses")
}
}
/// Recognizes a canonical snapshot result. The caller must first verify the owning
/// tool name and successful result status when reading conversation history.
pub fn task_list_from_tool_result(content: &str) -> Option<TaskListSnapshot> {
let result: TaskListToolResult = serde_json::from_str(content).ok()?;
let snapshot = TaskListSnapshot { tasks: result.tasks };
snapshot.validate().ok()?;
Some(snapshot)
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct TaskListToolResult {
#[serde(rename = "type")]
kind: TaskListResultKind,
tasks: Vec<TaskListItem>,
}
#[derive(Serialize, Deserialize)]
enum TaskListResultKind {
#[serde(rename = "task_list_snapshot")]
Snapshot,
}
#[cfg(test)]
#[path = "task_list_tests.rs"]
mod tests;
@@ -0,0 +1,157 @@
use serde_json::json;
use super::*;
fn task(id: &str, status: TaskListStatus) -> TaskListItem {
TaskListItem {
id: id.to_string(),
title: format!("Task {id}"),
status,
}
}
#[test]
fn snapshot_round_trips_through_a_recognizable_canonical_result() {
let arguments = json!({"tasks": [
{"id": "inspect", "title": "Inspect the failure", "status": "completed"},
{"id": "fix", "title": "Fix the failure", "status": "in_progress"},
{"id": "review", "title": "Review the fix", "status": "pending"},
]});
let snapshot = TaskListSnapshot::from_arguments(&arguments).unwrap();
let content = snapshot.to_tool_result_content();
assert_eq!(task_list_from_tool_result(&content), Some(snapshot.clone()));
assert_eq!(snapshot.to_tool_result_content(), content);
assert_eq!(
serde_json::from_str::<JsonValue>(&content).unwrap(),
json!({"type": "task_list_snapshot", "tasks": arguments["tasks"]}),
);
assert_eq!(snapshot.tasks[1].id, "fix");
assert_eq!(snapshot.tasks[1].status, TaskListStatus::InProgress);
}
#[test]
fn snapshot_rejects_invented_statuses_partial_arguments_and_unknown_fields() {
for arguments in [
json!({"tasks": []}),
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "started"}]}),
json!({"tasks": [{"id": "one", "title": "Inspect"}]}),
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "pending", "done": false}]}),
json!({"tasks": [{"id": "one", "title": "Inspect", "status": "pending"}], "append": true}),
json!([{"id": "one", "title": "Inspect", "status": "pending"}]),
] {
assert!(TaskListSnapshot::from_arguments(&arguments).is_err());
}
}
#[test]
fn argument_parser_accepts_only_the_reserved_tool_title_outside_the_snapshot() {
let tasks = json!([{"id": "one", "title": "Inspect", "status": "in_progress"}]);
let plain = TaskListSnapshot::from_arguments(&json!({"tasks": tasks})).unwrap();
assert_eq!(
TaskListSnapshot::from_arguments(&json!({
"tasks": tasks,
"_galaxy_tool_title": "Update progress",
})),
Ok(plain),
);
assert!(TaskListSnapshot::from_arguments(&json!({
"tasks": tasks,
"_galaxy_tool_title": false,
})).is_err());
assert!(TaskListSnapshot::from_arguments(&json!({
"tasks": tasks,
"_galaxy_tool_title": "Update progress",
"other": "unsupported",
})).is_err());
assert!(serde_json::from_value::<TaskListSnapshot>(json!({
"tasks": tasks,
"_galaxy_tool_title": "Update progress",
})).is_err());
}
#[test]
fn snapshot_enforces_stable_unique_nonempty_ids_and_nonempty_titles() {
for tasks in [
vec![task(" ", TaskListStatus::Pending)],
vec![task("", TaskListStatus::Pending)],
vec![
task("one", TaskListStatus::Completed),
task("one", TaskListStatus::Pending),
],
vec![TaskListItem {
id: "one".to_string(),
title: "\t\n".to_string(),
status: TaskListStatus::Pending,
}],
] {
assert!(TaskListSnapshot { tasks }.validate().is_err());
}
}
#[test]
fn snapshot_bounds_count_and_unicode_character_lengths() {
let mut snapshot = TaskListSnapshot {
tasks: (0..MAX_TASK_LIST_ITEMS)
.map(|index| task(&index.to_string(), TaskListStatus::Pending))
.collect(),
};
assert!(snapshot.validate().is_ok());
snapshot.tasks.push(task("extra", TaskListStatus::Pending));
assert!(snapshot.validate().is_err());
let mut snapshot = TaskListSnapshot {
tasks: vec![TaskListItem {
id: "🦀".repeat(MAX_TASK_ID_CHARS),
title: "界".repeat(MAX_TASK_TITLE_CHARS),
status: TaskListStatus::Pending,
}],
};
assert!(snapshot.validate().is_ok());
snapshot.tasks[0].id.push('🦀');
assert!(snapshot.validate().is_err());
snapshot.tasks[0].id.pop();
snapshot.tasks[0].title.push('界');
assert!(snapshot.validate().is_err());
}
#[test]
fn current_work_precedes_pending_tasks_while_completed_tasks_may_stay_in_place() {
let snapshot = TaskListSnapshot {
tasks: vec![
task("done", TaskListStatus::Completed),
task("working", TaskListStatus::InProgress),
task("also-done", TaskListStatus::Completed),
task("next", TaskListStatus::Pending),
],
};
assert!(snapshot.validate().is_ok());
for tasks in [
vec![
task("later", TaskListStatus::Pending),
task("done", TaskListStatus::Completed),
task("working", TaskListStatus::InProgress),
],
vec![
task("one", TaskListStatus::InProgress),
task("two", TaskListStatus::InProgress),
],
] {
assert!(TaskListSnapshot { tasks }.validate().is_err());
}
}
#[test]
fn result_parser_requires_marker_and_validates_snapshot_instead_of_trusting_content() {
let tasks = json!([{"id": "one", "title": "Inspect", "status": "pending"}]);
for content in [
json!({"tasks": tasks}).to_string(),
json!({"type": "something_else", "tasks": tasks}).to_string(),
json!({"type": "task_list_snapshot", "tasks": []}).to_string(),
json!({"type": "task_list_snapshot", "tasks": tasks, "unrelated": true}).to_string(),
"not JSON".to_string(),
] {
assert_eq!(task_list_from_tool_result(&content), None);
}
}
+17 -2
View File
@@ -3,8 +3,8 @@ use std::collections::{BTreeSet, HashMap, VecDeque};
use serde_json::Value as JsonValue;
use crate::{
ContentPart, ConversationMessage, MessageContent, ToolCall, ToolDefinition, ToolResult,
ToolResultStatus,
ContentPart, ConversationMessage, MessageContent, TaskListSnapshot, ToolCall, ToolDefinition,
ToolResult, ToolResultStatus, UPDATE_TASK_LIST_NAME,
};
pub const RECALL_TOOL_HISTORY_NAME: &str = "recall_tool_history";
@@ -145,6 +145,21 @@ impl ToolPolicy {
});
}
if call.name == UPDATE_TASK_LIST_NAME {
return match TaskListSnapshot::from_arguments(&call.arguments) {
Ok(snapshot) => ToolCallDecision::Inline(ToolResult {
call_id: call.id.clone(),
content: snapshot.to_tool_result_content(),
status: ToolResultStatus::Success,
}),
Err(error) => ToolCallDecision::Reject(ToolResult {
call_id: call.id.clone(),
content: format!("Error: {error}"),
status: ToolResultStatus::Error,
}),
};
}
ToolCallDecision::Execute
}
}
@@ -58,6 +58,62 @@ fn executes_only_tools_advertised_for_this_turn() {
);
}
#[test]
fn task_updates_are_inline_only_when_advertised_and_keep_the_call_identity() {
let call = ToolCall {
id: "task-update-1".to_string(),
name: UPDATE_TASK_LIST_NAME.to_string(),
arguments: serde_json::json!({"tasks": [
{"id": "fix", "title": "Fix the failure", "status": "in_progress"},
{"id": "review", "title": "Review the fix", "status": "pending"},
]}),
};
let unadvertised = ToolPolicy::new(&[definition("read_files")]);
let ToolCallDecision::Reject(result) = unadvertised.decide(&call, &[], &[]) else {
panic!("task updates cannot bypass advertised capabilities");
};
assert_eq!(result.call_id, call.id);
assert_eq!(result.status, ToolResultStatus::Error);
let policy = ToolPolicy::new(&[definition(UPDATE_TASK_LIST_NAME)]);
let ToolCallDecision::Inline(result) = policy.decide(&call, &[], &[]) else {
panic!("valid task updates must not dispatch external actions");
};
assert_eq!(result.call_id, call.id);
assert_eq!(result.status, ToolResultStatus::Success);
let snapshot = crate::task_list_from_tool_result(&result.content).unwrap();
assert_eq!(snapshot.tasks[0].id, "fix");
assert_eq!(snapshot.tasks[1].id, "review");
}
#[test]
fn malformed_task_updates_return_one_correlated_error_instead_of_executing() {
let policy = ToolPolicy::new(&[definition(UPDATE_TASK_LIST_NAME)]);
for arguments in [
serde_json::json!({"tasks": []}),
serde_json::json!({"tasks": [
{"id": "same", "title": "First", "status": "completed"},
{"id": "same", "title": "Second", "status": "pending"},
]}),
serde_json::json!({"tasks": [
{"id": "one", "title": "First", "status": "invented"},
]}),
] {
let call = ToolCall {
id: "invalid-task-update".to_string(),
name: UPDATE_TASK_LIST_NAME.to_string(),
arguments,
};
let ToolCallDecision::Reject(result) = policy.decide(&call, &[], &[]) else {
panic!("invalid task updates must not execute or succeed");
};
assert_eq!(result.call_id, call.id);
assert_eq!(result.status, ToolResultStatus::Error);
assert!(result.content.starts_with("Error: "));
assert_eq!(crate::task_list_from_tool_result(&result.content), None);
}
}
#[test]
fn recall_searches_archived_and_live_results_with_live_results_most_recent() {
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);