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
@@ -212,6 +212,8 @@ Key invariants:
- Same-conversation direct-provider follow-ups queue behind the cancelling generation; the old run keeps the active slot until terminal projection and cleanup, queued intent is checkpointed without credentials for restart recovery, and queued-only restore validates provider ownership and terminalizes the abandoned unprepared exchange from its persisted projection/stream identity before starting the successor; the next generation rebuilds provider history after cleanup so it includes the old generation's final committed output, and stale callbacks are ignored by stream identity - Same-conversation direct-provider follow-ups queue behind the cancelling generation; the old run keeps the active slot until terminal projection and cleanup, queued intent is checkpointed without credentials for restart recovery, and queued-only restore validates provider ownership and terminalizes the abandoned unprepared exchange from its persisted projection/stream identity before starting the successor; the next generation rebuilds provider history after cleanup so it includes the old generation's final committed output, and stale callbacks are ignored by stream identity
- Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs` - Known tools are in `KNOWN_TOOLS` in `response_translator.rs`; definitions are built by `tool_definition_for_name()` in `convert_request.rs`
- Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose - Direct-provider normal and plan turns must advertise `read_plan`, `create_plan`, and `edit_plan` when the matching document capabilities are enabled; plan-creation requests should call `create_plan` after research rather than only returning prose
- Direct-provider normal and plan turns expose `update_task_list` for explicit progress updates. Keep the validated current list in durable `ProviderRun` state, inject it into each request, preserve it across compaction and restore, and reconcile restored UI updates idempotently. Finishing a response must never mark unfinished work complete. CLI monitors and completed-command assessments retain their focused tool sets.
- Completed local plans are archived intact into `Plans/Archive` after seven days only when their conversation has explicitly completed all checklist items and has no active provider run or child. Draft, unfinished, or reopened plans stay visible; resumed work restores an automatically archived plan, and manual recovery is respected. Archival never deletes plan contents.
- Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation - Unknown or invalid tool calls receive one correlated synthetic error result and a visible `AgentOutput` message; the durable run owns any continuation
- `recall_tool_history` is an inline completed tool batch. `ProviderRun` commits its synthetic result and starts a bounded next turn without routing it through client action execution - `recall_tool_history` is an inline completed tool batch. `ProviderRun` commits its synthetic result and starts a bounded next turn without routing it through client action execution
- `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id` - `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id`
+11 -1
View File
@@ -83,7 +83,17 @@ impl AIAgentTodoList {
.position(|item| item.id == completed_todo_id.clone().into()) .position(|item| item.id == completed_todo_id.clone().into())
.map(|i| self.pending_items.remove(i)) .map(|i| self.pending_items.remove(i))
{ {
self.completed_items.push(item); // Restored provider batches can redeliver a pending update and
// its completion. Keep one completed item per stable task ID.
if let Some(previous) = self
.completed_items
.iter_mut()
.find(|old| old.id == item.id)
{
*previous = item;
} else {
self.completed_items.push(item);
}
} }
} }
} }
+22 -5
View File
@@ -6212,6 +6212,24 @@ impl BlocklistAIController {
}, },
ctx, ctx,
); );
// Projection can be persisted before a mixed tool batch commits. Seed
// from the actual visible list so replaying its inline update is a no-op.
let projected_task_list = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(&conversation_id)
.and_then(|conversation| {
crate::ai::runtime::task_list_from_api_tasks(
&conversation.compute_active_tasks(),
&root_task_id.to_string(),
)
});
let projector = ProviderRunResponseProjector::restored(
response_config.clone(),
projection_was_initialized,
)
.with_restored_task_lists(
projected_task_list,
crate::ai::runtime::restored_task_list(coordinator.run()),
);
self.active_provider_runs.insert( self.active_provider_runs.insert(
conversation_id, conversation_id,
ActiveProviderRunSlot { ActiveProviderRunSlot {
@@ -6223,10 +6241,7 @@ impl BlocklistAIController {
projection_target, projection_target,
run: Some(ActiveProviderRun { run: Some(ActiveProviderRun {
coordinator, coordinator,
projector: ProviderRunResponseProjector::restored( projector,
response_config.clone(),
projection_was_initialized,
),
response_config, response_config,
action_context, action_context,
messages_sent, messages_sent,
@@ -6681,9 +6696,11 @@ impl BlocklistAIController {
.active_provider_runs .active_provider_runs
.get(&conversation_id) .get(&conversation_id)
.and_then(|slot| slot.cancellation_reason); .and_then(|slot| slot.cancellation_reason);
let projector = ProviderRunResponseProjector::new(response_config.clone())
.with_task_list(coordinator.run().task_list().cloned());
let mut run = ActiveProviderRun { let mut run = ActiveProviderRun {
coordinator, coordinator,
projector: ProviderRunResponseProjector::new(response_config.clone()), projector,
response_config, response_config,
action_context, action_context,
messages_sent, messages_sent,
+1
View File
@@ -1,3 +1,4 @@
pub mod ai_document_model; pub mod ai_document_model;
pub mod orchestration_config_block; pub mod orchestration_config_block;
pub(crate) mod plan_archival;
pub(in crate::ai) mod plan_publication; pub(in crate::ai) mod plan_publication;
+500
View File
@@ -0,0 +1,500 @@
//! Retain completed plans in a recoverable Drive folder after one week.
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use chrono::{DateTime, Utc};
use galaxy_core::user_preferences::GetUserPreferences as _;
use galaxyui::r#async::Timer;
use galaxyui::{Entity, ModelContext, SingletonEntity};
use serde::{Deserialize, Serialize};
use super::ai_document_model::{
AIDocumentId, AIDocumentModel, AIDocumentModelEvent, AIDocumentUpdateSource,
AIDocumentUserEditStatus,
};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::artifacts::Artifact;
use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel};
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent};
use crate::cloud_object::{CloudObject, CloudObjectLocation, Space};
use crate::drive::CloudObjectTypeAndId;
use crate::local_object_repository::{
local_owner, LocalObjectRepository, LocalObjectRepositoryEvent,
};
use crate::server::ids::{ClientId, SyncId};
const PLAN_ARCHIVAL_KEY: &str = "CompletedPlanArchival";
const PLAN_FOLDER_NAME: &str = "Plans";
const ARCHIVE_FOLDER_NAME: &str = "Archive";
const SWEEP_INTERVAL: Duration = Duration::from_secs(5 * 60);
const ARCHIVE_AFTER_DAYS: i64 = 7;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CompletedPlan {
conversation_id: AIConversationId,
completed_at: DateTime<Utc>,
#[serde(default)]
archived_to: Option<SyncId>,
#[serde(default)]
original_folder: Option<SyncId>,
}
impl CompletedPlan {
fn is_due(&self, now: DateTime<Utc>) -> bool {
self.archived_to.is_none()
&& now.signed_duration_since(self.completed_at)
>= chrono::Duration::days(ARCHIVE_AFTER_DAYS)
}
}
/// Completion records are local preferences; notebook contents and folder
/// membership continue to use the normal SQLite-backed Drive repository.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PlanArchivalModel {
#[serde(default)]
completed_plans: HashMap<String, CompletedPlan>,
#[serde(default)]
reopened_plans: HashMap<String, ReopenedPlan>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ReopenedPlan {
conversation_id: AIConversationId,
/// The edited plan must reach a new agent exchange before the old task
/// list can be used as evidence of completion again.
saw_new_exchange: bool,
}
impl PlanArchivalModel {
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
let model = ctx
.private_user_preferences()
.read_value(PLAN_ARCHIVAL_KEY)
.unwrap_or_default()
.and_then(|json| Self::from_persisted(&json))
.unwrap_or_default();
ctx.subscribe_to_model(
&BlocklistAIHistoryModel::handle(ctx),
|model, _, event, ctx| match event {
BlocklistAIHistoryEvent::AppendedExchange { conversation_id, .. } => {
// Clear completion before a successor turn can start. A
// previously archived plan becomes visible again as well.
model.begin_exchange(*conversation_id, ctx);
}
BlocklistAIHistoryEvent::UpdatedConversationStatus { .. }
| BlocklistAIHistoryEvent::RestoredConversations { .. } => {
model.sweep(ctx);
}
_ => {}
},
);
ctx.subscribe_to_model(&AIDocumentModel::handle(ctx), |model, _, event, ctx| {
match event {
AIDocumentModelEvent::DocumentUpdated {
document_id,
source: AIDocumentUpdateSource::User,
..
}
| AIDocumentModelEvent::DocumentUserEditStatusUpdated {
document_id,
status: AIDocumentUserEditStatus::Dirty,
} => model.reopen_document(*document_id, ctx),
AIDocumentModelEvent::DocumentUserEditStatusUpdated {
document_id,
status: AIDocumentUserEditStatus::UpToDate,
} => model.accept_document_edits(*document_id, ctx),
_ => {}
}
});
ctx.subscribe_to_model(&CloudModel::handle(ctx), |model, _, event, ctx| {
if matches!(event, CloudModelEvent::InitialLoadCompleted) {
model.sweep(ctx);
}
});
ctx.subscribe_to_model(&LocalObjectRepository::handle(ctx), |model, _, event, ctx| {
let LocalObjectRepositoryEvent::NotebookContentEdited { id } = event else {
return;
};
let document_id = CloudModel::as_ref(ctx)
.get_notebook(id)
.and_then(|notebook| notebook.model().ai_document_id);
if let Some(document_id) = document_id {
model.reopen_document(document_id, ctx);
}
});
// The first callback runs after singleton registration and session
// restoration. Periodic checks also catch provider cleanup occurring
// just after a successful status event, without delaying that cleanup.
ctx.spawn(async {}, |model, _, ctx| {
model.sweep(ctx);
model.schedule_sweep(ctx);
});
model
}
fn from_persisted(json: &str) -> Option<Self> {
serde_json::from_str(json).ok().or_else(|| {
// Preserve completion records written before reopened-plan state
// was added to the preference payload.
serde_json::from_str(json).ok().map(|completed_plans| Self {
completed_plans,
..Self::default()
})
})
}
fn schedule_sweep(&self, ctx: &mut ModelContext<Self>) {
ctx.spawn(
async { Timer::after(SWEEP_INTERVAL).await },
|model, _, ctx| {
model.sweep(ctx);
model.schedule_sweep(ctx);
},
);
}
fn sweep(&mut self, ctx: &mut ModelContext<Self>) {
let before = self.completed_plans.clone();
let reopened_before = self.reopened_plans.clone();
self.reopened_plans.retain(|document_id, record| {
!record.saw_new_exchange
|| AIDocumentId::try_from(document_id.as_str())
.ok()
.and_then(|id| AIDocumentModel::as_ref(ctx).get_current_document(&id))
.is_none_or(|document| document.user_edit_status.is_dirty())
});
let observations = {
let history = BlocklistAIHistoryModel::as_ref(ctx);
let documents = AIDocumentModel::as_ref(ctx);
let mut seen = HashSet::new();
history
.all_live_conversations()
.into_iter()
.chain(history.all_cleared_conversations())
.filter(|(_, conversation)| seen.insert(conversation.id()))
.map(|(_, conversation)| {
let mut document_ids = documents
.get_all_documents_for_conversation(conversation.id())
.into_iter()
.map(|(id, _)| id.to_string())
.collect::<HashSet<_>>();
document_ids.extend(conversation.artifacts().iter().filter_map(
|artifact| {
if let Artifact::Plan { document_uid, .. } = artifact {
Some(document_uid.clone())
} else {
None
}
},
));
(
conversation.id(),
document_ids,
completed_work_time(conversation),
)
})
.collect::<Vec<_>>()
};
for (conversation_id, document_ids, completed_at) in observations {
match completed_at {
Some(completed_at) => {
for document_id in document_ids {
self.observe_completion(document_id, conversation_id, completed_at);
}
}
None => self.resume_conversation(conversation_id, ctx),
}
}
// Persisted completion records also cover unloaded conversations.
// AppendedExchange clears them before later work starts. Do not use
// metadata.last_modified_at as work identity: checkpoint and title
// saves update that timestamp after the exchange has already finished.
let now = Utc::now();
let due = self
.completed_plans
.iter()
.filter(|(_, record)| record.is_due(now))
.map(|(document_id, _)| document_id.clone())
.collect::<Vec<_>>();
for document_id in due {
self.archive_plan(&document_id, ctx);
}
if before != self.completed_plans || reopened_before != self.reopened_plans {
self.persist(ctx);
}
}
fn observe_completion(
&mut self,
document_id: String,
conversation_id: AIConversationId,
completed_at: DateTime<Utc>,
) {
if self.reopened_plans.contains_key(&document_id) {
return;
}
if self
.completed_plans
.get(&document_id)
.is_some_and(|record| record.conversation_id == conversation_id)
{
return;
}
self.completed_plans.insert(
document_id,
CompletedPlan {
conversation_id,
completed_at,
archived_to: None,
original_folder: None,
},
);
}
fn archive_plan(&mut self, document_id: &str, ctx: &mut ModelContext<Self>) {
if self.reopened_plans.contains_key(document_id) {
return;
}
if let Some(id) = AIDocumentId::try_from(document_id).ok().filter(|id| {
AIDocumentModel::as_ref(ctx)
.get_current_document(id)
.is_some_and(|document| document.user_edit_status.is_dirty())
}) {
self.reopen_document(id, ctx);
return;
}
let notebook = CloudModel::as_ref(ctx)
.get_all_active_notebooks()
.find(|notebook| {
notebook.permissions.owner == local_owner()
&& notebook
.model()
.ai_document_id
.is_some_and(|id| id.to_string() == document_id)
})
.cloned();
let Some(notebook) = notebook else {
return;
};
let plans_folder = get_or_create_folder(PLAN_FOLDER_NAME, None, ctx);
let archive_folder = get_or_create_folder(ARCHIVE_FOLDER_NAME, Some(plans_folder), ctx);
let original_folder = notebook.metadata.folder_id;
if original_folder != Some(archive_folder)
&& !LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(notebook.id),
CloudObjectLocation::Folder(archive_folder),
ctx,
)
})
{
return;
}
if let Some(record) = self.completed_plans.get_mut(document_id) {
record.original_folder = original_folder.filter(|id| *id != archive_folder);
record.archived_to = Some(archive_folder);
}
}
fn resume_conversation(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let document_ids = self
.completed_plans
.iter()
.filter(|(_, record)| record.conversation_id == conversation_id)
.map(|(document_id, _)| document_id.clone())
.collect::<Vec<_>>();
if document_ids.is_empty() {
return;
}
for document_id in document_ids {
let Some(record) = self.completed_plans.remove(&document_id) else {
continue;
};
restore_archived_plan(&document_id, record, ctx);
}
self.persist(ctx);
}
fn begin_exchange(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
self.resume_conversation(conversation_id, ctx);
let mut changed = false;
self.reopened_plans.retain(|document_id, record| {
if record.conversation_id != conversation_id {
return true;
}
record.saw_new_exchange = true;
changed = true;
let document = AIDocumentId::try_from(document_id.as_str())
.ok()
.and_then(|id| AIDocumentModel::as_ref(ctx).get_current_document(&id));
document.is_none_or(|document| document.user_edit_status.is_dirty())
});
if changed {
self.persist(ctx);
}
}
fn reopen_document(&mut self, document_id: AIDocumentId, ctx: &mut ModelContext<Self>) {
let key = document_id.to_string();
let completed = self.completed_plans.remove(&key);
// Restored free-floating document views can have a synthetic owner.
// Prefer the real owner retained in the completion/reopening record.
let conversation_id = completed
.as_ref()
.map(|record| record.conversation_id)
.or_else(|| {
self.reopened_plans
.get(&key)
.map(|record| record.conversation_id)
})
.or_else(|| {
AIDocumentModel::as_ref(ctx).get_conversation_id_for_document_id(&document_id)
});
if let Some(conversation_id) = conversation_id {
self.reopened_plans.insert(
key.clone(),
ReopenedPlan {
conversation_id,
saw_new_exchange: false,
},
);
}
if let Some(record) = completed {
restore_archived_plan(&key, record, ctx);
}
self.persist(ctx);
}
fn accept_document_edits(&mut self, document_id: AIDocumentId, ctx: &mut ModelContext<Self>) {
let key = document_id.to_string();
if self
.reopened_plans
.get(&key)
.is_some_and(|record| record.saw_new_exchange)
{
self.reopened_plans.remove(&key);
self.persist(ctx);
}
}
fn persist(&self, ctx: &mut ModelContext<Self>) {
match serde_json::to_string(self) {
Ok(json) => {
if let Err(error) = ctx
.private_user_preferences()
.write_value(PLAN_ARCHIVAL_KEY, json)
{
log::error!("Failed to persist completed plan retention: {error}");
}
}
Err(error) => log::error!("Failed to serialize completed plan retention: {error}"),
}
}
}
fn restore_archived_plan(
document_id: &str,
record: CompletedPlan,
ctx: &mut ModelContext<PlanArchivalModel>,
) {
let Some(archive_folder) = record.archived_to else {
return;
};
let notebook = CloudModel::as_ref(ctx)
.get_all_active_notebooks()
.find(|notebook| {
notebook.permissions.owner == local_owner()
&& notebook.metadata.folder_id == Some(archive_folder)
&& notebook
.model()
.ai_document_id
.is_some_and(|id| id.to_string() == document_id)
})
.cloned();
let Some(notebook) = notebook else {
return;
};
let original_folder = record.original_folder.filter(|id| {
let cloud_model = CloudModel::as_ref(ctx);
cloud_model.get_folder(id).is_some_and(|folder| {
folder.permissions.owner == local_owner() && !folder.is_trashed(cloud_model)
})
});
let destination = original_folder
.map(CloudObjectLocation::Folder)
.unwrap_or(CloudObjectLocation::Space(Space::Personal));
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(notebook.id),
destination,
ctx,
);
});
}
fn completed_work_time(conversation: &AIConversation) -> Option<DateTime<Utc>> {
if !matches!(conversation.status(), ConversationStatus::Success)
|| conversation.active_provider_run_json().is_some()
|| conversation.has_active_subagent()
|| !conversation
.active_todo_list()
.is_some_and(|list| list.is_finished())
{
return None;
}
conversation
.latest_exchange()
.and_then(|exchange| exchange.finish_time)
.map(|time| time.with_timezone(&Utc))
}
fn get_or_create_folder(
name: &str,
parent: Option<SyncId>,
ctx: &mut ModelContext<PlanArchivalModel>,
) -> SyncId {
if let Some(id) = CloudModel::as_ref(ctx)
.get_all_active_folders()
.find(|folder| {
folder.permissions.owner == local_owner()
&& folder.model().name == name
&& folder.metadata.folder_id == parent
})
.map(|folder| folder.id)
{
return id;
}
let id = SyncId::ClientId(ClientId::new());
LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
repository.create_folder_with_id(id, name.to_string(), parent, ctx);
});
id
}
impl Entity for PlanArchivalModel {
type Event = ();
}
impl SingletonEntity for PlanArchivalModel {}
#[cfg(test)]
#[path = "plan_archival_tests.rs"]
mod tests;
+417
View File
@@ -0,0 +1,417 @@
use std::sync::mpsc::sync_channel;
use ai::document::AIDocumentId;
use galaxyui::App;
use super::*;
use crate::notebooks::CloudNotebookModel;
use crate::persistence::ModelEvent;
use crate::test_util::settings::initialize_settings_for_tests;
fn completed_plan(completed_at: DateTime<Utc>) -> CompletedPlan {
CompletedPlan {
conversation_id: AIConversationId::new(),
completed_at,
archived_to: None,
original_folder: None,
}
}
#[test]
fn completed_plan_archives_after_seven_full_days_only() {
let now = Utc::now();
let plan = completed_plan(now - chrono::Duration::days(7));
assert!(!plan.is_due(now - chrono::Duration::milliseconds(1)));
assert!(plan.is_due(now));
assert!(!completed_plan(now + chrono::Duration::days(1)).is_due(now));
}
#[test]
fn archived_plan_deadline_and_recovery_location_survive_restart() {
let mut plan = completed_plan(Utc::now() - chrono::Duration::days(8));
plan.archived_to = Some(SyncId::ClientId(ClientId::new()));
plan.original_folder = Some(SyncId::ClientId(ClientId::new()));
let serialized = serde_json::to_string(&plan).unwrap();
let restored: CompletedPlan = serde_json::from_str(&serialized).unwrap();
assert_eq!(restored, plan);
assert!(!restored.is_due(Utc::now()));
}
#[test]
fn revisiting_a_completed_plan_preserves_its_original_deadline() {
let mut model = PlanArchivalModel {
completed_plans: HashMap::new(),
..Default::default()
};
let conversation_id = AIConversationId::new();
let completed_at = Utc::now() - chrono::Duration::days(6);
model.observe_completion("plan".to_string(), conversation_id, completed_at);
model.observe_completion("plan".to_string(), conversation_id, Utc::now());
assert_eq!(model.completed_plans["plan"].completed_at, completed_at);
}
#[test]
fn legacy_completed_plan_records_are_preserved() {
let record = completed_plan(Utc::now() - chrono::Duration::days(4));
let records = HashMap::from([("plan".to_string(), record.clone())]);
let json = serde_json::to_string(&records).unwrap();
let model = PlanArchivalModel::from_persisted(&json).unwrap();
assert_eq!(model.completed_plans["plan"], record);
assert!(model.reopened_plans.is_empty());
}
#[test]
fn dirty_and_reverted_plans_stay_visible_until_new_work_accepts_the_edits() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
let documents = app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let document_id = AIDocumentId::new();
let notebook_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(
notebook_id,
None,
CloudNotebookModel {
title: "Reopened plan".to_string(),
data: "New unfinished work".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
ctx,
);
});
let archival = app.add_model(PlanArchivalModel::new);
let conversation_id = AIConversationId::new();
let old_completion = Utc::now() - chrono::Duration::days(8);
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, old_completion);
model.archive_plan(&document_id.to_string(), ctx);
});
assert!(CloudModel::as_ref(&app)
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id
.is_some());
// Typing in the editor emits Dirty; reverting a version also emits a
// user-originated DocumentUpdated event. Either must reopen the plan.
for event in [
AIDocumentModelEvent::DocumentUserEditStatusUpdated {
document_id,
status: AIDocumentUserEditStatus::Dirty,
},
AIDocumentModelEvent::DocumentUpdated {
document_id,
version: Default::default(),
source: AIDocumentUpdateSource::User,
},
] {
documents.update(&mut app, |_, ctx| ctx.emit(event));
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, old_completion);
model.sweep(ctx);
assert!(!model.completed_plans.contains_key(&document_id.to_string()));
assert_eq!(
model.reopened_plans[&document_id.to_string()].conversation_id,
conversation_id,
);
let json = serde_json::to_string(model).unwrap();
let mut restored = PlanArchivalModel::from_persisted(&json).unwrap();
restored.observe_completion(
document_id.to_string(),
conversation_id,
old_completion,
);
assert!(restored.completed_plans.is_empty());
});
assert!(CloudModel::as_ref(&app)
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id
.is_none());
}
archival.update(&mut app, |model, ctx| {
// Merely restoring clean editor state cannot close a reopened plan.
model.accept_document_edits(document_id, ctx);
assert!(model.reopened_plans.contains_key(&document_id.to_string()));
model.begin_exchange(conversation_id, ctx);
assert!(model.reopened_plans.contains_key(&document_id.to_string()));
model.accept_document_edits(document_id, ctx);
assert!(model.reopened_plans.is_empty());
let finished_again = Utc::now();
model.observe_completion(document_id.to_string(), conversation_id, finished_again);
assert_eq!(model.completed_plans[&document_id.to_string()].completed_at, finished_again);
assert!(!model.completed_plans[&document_id.to_string()].is_due(finished_again));
});
});
}
#[test]
fn a_successful_draft_conversation_does_not_complete_a_plan() {
let mut conversation = AIConversation::new(false, false);
conversation.set_status_for_test(ConversationStatus::Success);
assert!(completed_work_time(&conversation).is_none());
for status in [
ConversationStatus::InProgress,
ConversationStatus::WaitingForEvents,
ConversationStatus::TransientError,
ConversationStatus::Error,
ConversationStatus::Cancelled,
] {
conversation.set_status_for_test(status);
assert!(completed_work_time(&conversation).is_none());
}
}
#[test]
fn archival_preserves_notebook_and_resuming_restores_original_folder() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let (sender, receiver) = sync_channel(32);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(Some(sender), None, ctx)
});
let archival = app.add_model(|_| PlanArchivalModel {
completed_plans: HashMap::new(),
..Default::default()
});
let original_folder = SyncId::ClientId(ClientId::new());
let notebook_id = SyncId::ClientId(ClientId::new());
let document_id = AIDocumentId::new();
let notebook_model = CloudNotebookModel {
title: "Retained plan".to_string(),
data: "# Plan\n- [x] Finished work".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
};
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(
original_folder,
"Project".to_string(),
None,
ctx,
);
repository.create_notebook_with_id(
notebook_id,
Some(original_folder),
notebook_model.clone(),
ctx,
);
});
let conversation_id = AIConversationId::new();
archival.update(&mut app, |model, ctx| {
model.observe_completion(
document_id.to_string(),
conversation_id,
Utc::now() - chrono::Duration::days(8),
);
model.persist(ctx);
});
// Simulate startup with persisted completion records and no loaded
// conversations/documents. The notebook is still retained in Drive.
let archival = app.add_model(PlanArchivalModel::new);
archival.update(&mut app, |model, ctx| model.sweep(ctx));
let archive_folder = archival.read(&app, |model, _| {
model.completed_plans[&document_id.to_string()]
.archived_to
.expect("plan should have been archived")
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert_eq!(notebook.model(), &notebook_model);
assert_eq!(notebook.metadata.folder_id, Some(archive_folder));
assert!(!notebook.is_trashed(cloud_model));
let folder = cloud_model.get_folder(&archive_folder).unwrap();
assert_eq!(folder.model().name, ARCHIVE_FOLDER_NAME);
let parent = cloud_model
.get_folder(&folder.metadata.folder_id.unwrap())
.unwrap();
assert_eq!(parent.model().name, PLAN_FOLDER_NAME);
let events = receiver.try_iter().collect::<Vec<_>>();
assert!(events.iter().any(|event| matches!(
event,
ModelEvent::UpsertNotebook { notebook }
if notebook.id == notebook_id
&& notebook.metadata.folder_id == Some(archive_folder)
&& notebook.model() == &notebook_model
)));
assert!(!events.iter().any(|event| matches!(event, ModelEvent::DeleteObjects { .. })));
archival.update(&mut app, |model, ctx| {
model.resume_conversation(conversation_id, ctx);
assert!(model.completed_plans.is_empty());
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert_eq!(notebook.metadata.folder_id, Some(original_folder));
assert_eq!(notebook.model(), &notebook_model);
assert!(!notebook.is_trashed(cloud_model));
});
}
#[test]
fn manual_restore_does_not_rearchive_a_completed_plan() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let archive_folder = SyncId::ClientId(ClientId::new());
let notebook_id = SyncId::ClientId(ClientId::new());
let document_id = AIDocumentId::new();
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(archive_folder, "Archive".to_string(), None, ctx);
repository.create_notebook_with_id(
notebook_id,
Some(archive_folder),
CloudNotebookModel {
title: "Recovered plan".to_string(),
data: "Keep this plan visible".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
ctx,
);
});
let mut record = completed_plan(Utc::now() - chrono::Duration::days(8));
record.archived_to = Some(archive_folder);
let archival = app.add_model(|_| PlanArchivalModel {
completed_plans: HashMap::from([(document_id.to_string(), record)]),
..Default::default()
});
assert!(repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(notebook_id),
CloudObjectLocation::Space(Space::Personal),
ctx,
)
}));
archival.update(&mut app, |model, ctx| model.sweep(ctx));
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert!(notebook.metadata.folder_id.is_none());
assert_eq!(notebook.model().data, "Keep this plan visible");
archival.read(&app, |model, _| {
assert!(!model.completed_plans[&document_id.to_string()].is_due(Utc::now()));
});
});
}
#[test]
fn ordinary_notebooks_are_never_archived() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let notebook_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(
notebook_id,
None,
CloudNotebookModel {
title: "Ordinary notes".to_string(),
data: "Keep these notes".to_string(),
ai_document_id: None,
conversation_id: None,
},
ctx,
);
});
let archival = app.add_model(|_| PlanArchivalModel {
completed_plans: HashMap::new(),
..Default::default()
});
archival.update(&mut app, |model, ctx| {
model.observe_completion(
notebook_id.to_string(),
AIConversationId::new(),
Utc::now() - chrono::Duration::days(8),
);
model.archive_plan(&notebook_id.to_string(), ctx);
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert!(notebook.metadata.folder_id.is_none());
assert_eq!(notebook.model().data, "Keep these notes");
assert_eq!(cloud_model.get_all_active_folders().count(), 0);
});
}
#[test]
fn editing_an_archived_plan_from_drive_reopens_it_without_loading_its_conversation() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
app.add_singleton_model(|_| CloudModel::new(None, Vec::new(), None));
app.add_singleton_model(|_| BlocklistAIHistoryModel::new_for_test());
app.add_singleton_model(|_| AIDocumentModel::new_for_test());
let repository = app.add_singleton_model(|ctx| {
LocalObjectRepository::new(None, None, ctx)
});
let document_id = AIDocumentId::new();
let notebook_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(
notebook_id,
None,
CloudNotebookModel {
title: "Completed plan".to_string(),
data: "- [x] Done".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
ctx,
);
});
let archival = app.add_model(PlanArchivalModel::new);
let conversation_id = AIConversationId::new();
let completed_at = Utc::now() - chrono::Duration::days(8);
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, completed_at);
model.archive_plan(&document_id.to_string(), ctx);
});
repository.update(&mut app, |repository, ctx| {
repository.update_notebook_data(
notebook_id,
"- [ ] New unfinished work".to_string(),
ctx,
);
});
archival.update(&mut app, |model, ctx| {
model.observe_completion(document_id.to_string(), conversation_id, completed_at);
model.sweep(ctx);
assert!(model.completed_plans.is_empty());
assert_eq!(
model.reopened_plans[&document_id.to_string()].conversation_id,
conversation_id,
);
});
let cloud_model = CloudModel::as_ref(&app);
let notebook = cloud_model.get_notebook(&notebook_id).unwrap();
assert!(notebook.metadata.folder_id.is_none());
assert_eq!(notebook.model().data, "- [ ] New unfinished work");
assert!(!notebook.is_trashed(cloud_model));
});
}
@@ -1377,6 +1377,7 @@ impl AIExecutionProfilesModel {
} }
LocalObjectRepositoryEvent::Rules LocalObjectRepositoryEvent::Rules
| LocalObjectRepositoryEvent::Notebooks { .. } | LocalObjectRepositoryEvent::Notebooks { .. }
| LocalObjectRepositoryEvent::NotebookContentEdited { .. }
| LocalObjectRepositoryEvent::Workflows { .. } => {} | LocalObjectRepositoryEvent::Workflows { .. } => {}
} }
} }
@@ -1625,6 +1625,7 @@ const KNOWN_TOOLS: &[&str] = &[
"read_skill", "read_skill",
"fetch_conversation", "fetch_conversation",
"recall_tool_history", "recall_tool_history",
"update_task_list",
]; ];
pub(super) fn is_known_tool(name: &str) -> bool { pub(super) fn is_known_tool(name: &str) -> bool {
+85 -57
View File
@@ -2,13 +2,14 @@ use std::collections::HashMap;
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentEvent, ProviderRunOutcome, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities, AgentEvent, ProviderRunOutcome, RuntimeActivity, RuntimeActivityStatus, RuntimeCapabilities,
StopReason, Usage, StopReason, TaskListSnapshot, Usage,
}; };
use uuid::Uuid; use uuid::Uuid;
use warp_multi_agent_api::response_event::stream_finished; use warp_multi_agent_api::response_event::stream_finished;
use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent}; use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use super::provider_run_coordinator::ProviderRunProjection; use super::provider_run_coordinator::ProviderRunProjection;
use super::task_progress::{explicit_task_updates, task_list_from_plan_batch, todo_operations};
use crate::ai::agent::runtime_activity; use crate::ai::agent::runtime_activity;
use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage}; use crate::ai::openai::response_translator::{build_stream_finished, StreamUsage};
use crate::ai::provider::response_translator::{ use crate::ai::provider::response_translator::{
@@ -54,6 +55,8 @@ pub(crate) struct ProviderRunResponseProjector {
translator: RuntimeResponseTranslator, translator: RuntimeResponseTranslator,
has_started_model_turn: bool, has_started_model_turn: bool,
plan_tasks_projected: bool, plan_tasks_projected: bool,
task_list: Option<TaskListSnapshot>,
pending_task_list: Option<TaskListSnapshot>,
finished: bool, finished: bool,
} }
@@ -63,6 +66,8 @@ impl ProviderRunResponseProjector {
translator: RuntimeResponseTranslator::new(config), translator: RuntimeResponseTranslator::new(config),
has_started_model_turn: false, has_started_model_turn: false,
plan_tasks_projected: false, plan_tasks_projected: false,
task_list: None,
pending_task_list: None,
finished: false, finished: false,
} }
} }
@@ -75,10 +80,45 @@ impl ProviderRunResponseProjector {
translator: RuntimeResponseTranslator::restored(config, projection_was_initialized), translator: RuntimeResponseTranslator::restored(config, projection_was_initialized),
has_started_model_turn: false, has_started_model_turn: false,
plan_tasks_projected: true, plan_tasks_projected: true,
task_list: None,
pending_task_list: None,
finished: false, finished: false,
} }
} }
pub(crate) fn with_task_list(mut self, task_list: Option<TaskListSnapshot>) -> Self {
self.plan_tasks_projected |= task_list.is_some();
self.task_list = task_list;
self
}
pub(crate) fn with_restored_task_lists(
mut self,
visible: Option<TaskListSnapshot>,
desired: Option<TaskListSnapshot>,
) -> Self {
self.plan_tasks_projected = visible.is_some() || desired.is_some();
self.task_list = visible;
self.pending_task_list =
desired.filter(|snapshot| self.task_list.as_ref() != Some(snapshot));
self
}
fn reconcile_restored_tasks(&mut self) -> Vec<ResponseEvent> {
let Some(snapshot) = self.pending_task_list.take() else {
return Vec::new();
};
let mut events = Vec::new();
self.translator.initialize(&mut events);
events.extend(
todo_operations(self.task_list.as_ref(), &snapshot)
.into_iter()
.map(|operation| build_todo_update(&self.translator.config.task_id, operation)),
);
self.task_list = Some(snapshot);
events
}
pub(crate) fn project( pub(crate) fn project(
&mut self, &mut self,
projection: ProviderRunProjection, projection: ProviderRunProjection,
@@ -86,7 +126,8 @@ impl ProviderRunResponseProjector {
if self.finished { if self.finished {
return Err("provider run projection is already finished".to_string()); return Err("provider run projection is already finished".to_string());
} }
match projection { let mut events = self.reconcile_restored_tasks();
let projected = match projection {
ProviderRunProjection::ModelTurnStarted { .. } => { ProviderRunProjection::ModelTurnStarted { .. } => {
if self.has_started_model_turn { if self.has_started_model_turn {
self.translator.begin_followup_turn(); self.translator.begin_followup_turn();
@@ -111,23 +152,28 @@ impl ProviderRunResponseProjector {
} }
ProviderRunProjection::ModelTurnFinished { .. } => Ok(Vec::new()), ProviderRunProjection::ModelTurnFinished { .. } => Ok(Vec::new()),
ProviderRunProjection::ToolBatchReady { batch } => { ProviderRunProjection::ToolBatchReady { batch } => {
if !self.plan_tasks_projected { let mut snapshots = explicit_task_updates(&batch);
if let Some(todos) = todos_from_plan_batch(&batch) { if snapshots.is_empty() && !self.plan_tasks_projected {
self.plan_tasks_projected = true; snapshots.extend(task_list_from_plan_batch(&batch));
return Ok(vec![build_todo_update( }
&self.translator.config.task_id, let mut events = Vec::new();
api::message::update_todos::Operation::CreateTodoList( for snapshot in snapshots {
api::CreateTodoList { events.extend(
initial_todos: todos, todo_operations(self.task_list.as_ref(), &snapshot)
}, .into_iter()
), .map(|operation| {
)]); build_todo_update(&self.translator.config.task_id, operation)
} }),
);
self.task_list = Some(snapshot);
self.plan_tasks_projected = true;
} }
// Tool batches indicate runtime activity, not completion of planned work. // Tool batches indicate runtime activity, not completion of planned work.
Ok(Vec::new()) Ok(events)
} }
} }?;
events.extend(projected);
Ok(events)
} }
pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) { pub(crate) fn set_task_id(&mut self, task_id: impl Into<String>) {
@@ -143,19 +189,22 @@ impl ProviderRunResponseProjector {
return Err("provider run projection is already finished".to_string()); return Err("provider run projection is already finished".to_string());
} }
self.finished = true; self.finished = true;
match outcome { let mut events = self.reconcile_restored_tasks();
ProviderRunOutcome::Completed(completion) => Ok(self let terminal = match outcome {
ProviderRunOutcome::Completed(completion) => self
.translator .translator
.finish_provider_run(completion.stop_reason.clone(), aggregate_usage)), .finish_provider_run(completion.stop_reason.clone(), aggregate_usage),
ProviderRunOutcome::Failed(failure) => Ok(self.translator.provider_failure( ProviderRunOutcome::Failed(failure) => self.translator.provider_failure(
&failure.message, &failure.message,
failure.source.as_ref(), failure.source.as_ref(),
aggregate_usage, aggregate_usage,
)), ),
ProviderRunOutcome::Cancelled { .. } => Ok(self ProviderRunOutcome::Cancelled { .. } => self
.translator .translator
.finish_provider_run(StopReason::Cancelled, aggregate_usage)), .finish_provider_run(StopReason::Cancelled, aggregate_usage),
} };
events.extend(terminal);
Ok(events)
} }
} }
@@ -174,42 +223,21 @@ fn authentication_provider_for_runtime(runtime_id: &str) -> api::LlmProvider {
// Only explicit multi-step checklists in a plan become tasks. Other document bullets // Only explicit multi-step checklists in a plan become tasks. Other document bullets
// (risks, examples, requirements) are not executable tasks. // (risks, examples, requirements) are not executable tasks.
#[cfg(test)]
fn todos_from_plan_batch( fn todos_from_plan_batch(
batch: &galaxy_agent_core::PendingToolBatch, batch: &galaxy_agent_core::PendingToolBatch,
) -> Option<Vec<api::TodoItem>> { ) -> Option<Vec<api::TodoItem>> {
let plan_call = batch.calls.iter().find(|pending| { task_list_from_plan_batch(batch).map(|snapshot| {
matches!( snapshot
pending.call.name.as_str(), .tasks
"create_plan" | "create_documents" .into_iter()
) .map(|task| api::TodoItem {
})?; id: task.id,
let content = plan_call description: task.title.clone(),
.call title: task.title,
.arguments })
.get("documents")? .collect()
.as_array()? })
.first()?
.get("content")?
.as_str()?;
let mut lines = content.lines();
lines.find(|line| matches!(line.trim(), "## Tasks" | "## Implementation Tasks"))?;
let todos = lines
.take_while(|line| !line.trim().starts_with("## "))
.filter_map(|line| line.trim().strip_prefix("- [ ]").map(str::trim))
.filter(|item| !item.is_empty())
.take(50)
.enumerate()
.map(|(index, item)| api::TodoItem {
id: format!("plan-{}-{index}", plan_call.call.id),
title: item
.split_once(" - ")
.map_or(item, |(title, _)| title)
.trim()
.to_owned(),
description: item.to_owned(),
})
.collect::<Vec<_>>();
(todos.len() > 1).then_some(todos)
} }
fn build_todo_update( fn build_todo_update(
@@ -82,6 +82,64 @@ fn restored_provider_projection_skips_stream_initialization() {
)); ));
} }
#[test]
fn restored_task_progress_replay_does_not_duplicate_the_persisted_checklist() {
use galaxy_agent_core::{TaskListItem, TaskListSnapshot, TaskListStatus};
let snapshot = TaskListSnapshot { tasks: vec![
TaskListItem { id: "inspect".to_owned(), title: "Inspect".to_owned(), status: TaskListStatus::Completed },
TaskListItem { id: "verify".to_owned(), title: "Verify".to_owned(), status: TaskListStatus::InProgress },
] };
let mut projector = ProviderRunResponseProjector::restored(RuntimeResponseConfig {
task_id: "task".to_owned(), conversation_id: "conversation".to_owned(), needs_create_task: false,
user_query: None, model_id: "model".to_owned(), max_context_tokens: Some(1000),
capabilities: RuntimeCapabilities::provider(), empty_output_message: None,
}, true).with_task_list(Some(snapshot.clone()));
let batch = galaxy_agent_core::PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"), epoch: galaxy_agent_core::RunEpoch::new(1),
},
calls: vec![galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: "progress".to_owned(), name: galaxy_agent_core::UPDATE_TASK_LIST_NAME.to_owned(),
arguments: serde_json::to_value(&snapshot).unwrap(),
},
state: galaxy_agent_core::PendingToolCallState::Resolved { result: galaxy_agent_core::ToolResult {
call_id: "progress".to_owned(), status: galaxy_agent_core::ToolResultStatus::Success,
content: snapshot.to_tool_result_content(),
}},
}],
};
assert!(projector.project(ProviderRunProjection::ToolBatchReady { batch }).unwrap().is_empty());
}
#[test]
fn restored_committed_task_snapshot_repairs_missing_projection_even_on_terminal_cleanup() {
use galaxy_agent_core::{TaskListItem, TaskListSnapshot, TaskListStatus};
let snapshot = TaskListSnapshot { tasks: vec![TaskListItem {
id: "verify".to_owned(), title: "Verify".to_owned(), status: TaskListStatus::Completed,
}] };
let mut projector = ProviderRunResponseProjector::restored(RuntimeResponseConfig {
task_id: "task".to_owned(), conversation_id: "conversation".to_owned(), needs_create_task: false,
user_query: None, model_id: "model".to_owned(), max_context_tokens: Some(1000),
capabilities: RuntimeCapabilities::provider(), empty_output_message: None,
}, true).with_restored_task_lists(None, Some(snapshot));
let events = projector.finish(&galaxy_agent_core::ProviderRunOutcome::Cancelled {
reason: "restored cancellation".to_owned(),
}, &Usage::default()).unwrap();
let operations = events.into_iter().filter_map(|event| {
let Some(response_event::Type::ClientActions(actions)) = event.r#type else { return None };
Some(actions.actions.into_iter().filter_map(|action| {
let Some(client_action::Action::AddMessagesToTask(add)) = action.action else { return None };
Some(add.messages.into_iter().filter_map(|message| {
let Some(message::Message::UpdateTodos(update)) = message.message else { return None };
update.operation
}).collect::<Vec<_>>())
}).flatten().collect::<Vec<_>>())
}).flatten().collect::<Vec<_>>();
assert!(matches!(&operations[0], message::update_todos::Operation::CreateTodoList(_)));
assert!(matches!(&operations[1], message::update_todos::Operation::MarkTodosCompleted(mark) if mark.todo_ids == ["verify"]));
}
#[test] #[test]
fn provider_activity_does_not_create_or_complete_task_lists() { fn provider_activity_does_not_create_or_complete_task_lists() {
let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig { let mut projector = ProviderRunResponseProjector::new(RuntimeResponseConfig {
+2
View File
@@ -4,6 +4,7 @@ mod provider_run_coordinator;
mod rig; mod rig;
mod rig_request; mod rig_request;
mod rig_tool; mod rig_tool;
mod task_progress;
pub(crate) use child_context::add_parent_context; pub(crate) use child_context::add_parent_context;
pub(crate) use event_translator::{ pub(crate) use event_translator::{
@@ -19,3 +20,4 @@ pub(crate) use rig::{
PreparedProviderRun, ProviderActionContext, PreparedProviderRun, ProviderActionContext,
}; };
pub(crate) use rig_request::OrchestrationModelOption; pub(crate) use rig_request::OrchestrationModelOption;
pub(crate) use task_progress::{restored_task_list, task_list_from_api_tasks};
+17 -3
View File
@@ -12,11 +12,12 @@ use galaxy_agent_core::{
ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile, ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile,
ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome,
ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch,
RuntimeKind, StopReason, ToolEvent, TurnCommand, TurnControl, TurnRequest, Usage, RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest, Usage,
}; };
use instant::Instant; use instant::Instant;
use warpui::r#async::Timer; use warpui::r#async::Timer;
use super::task_progress::{add_task_list_context, initial_task_list, task_list_from_plan_batch};
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::model_output_logging; use crate::ai::model_output_logging;
@@ -205,6 +206,7 @@ impl ProviderRunCoordinator {
limits, limits,
); );
run.replace_tool_result_archive(tool_result_archive); run.replace_tool_result_archive(tool_result_archive);
run.set_task_list(initial_task_list(&base_profile.request))?;
let mut profiles = BTreeMap::new(); let mut profiles = BTreeMap::new();
profiles.insert(BASE_PROVIDER_PROFILE.to_string(), base_profile); profiles.insert(BASE_PROVIDER_PROFILE.to_string(), base_profile);
Self::new(run, profiles) Self::new(run, profiles)
@@ -271,6 +273,7 @@ impl ProviderRunCoordinator {
let mut request = profile.request.clone(); let mut request = profile.request.clone();
request.prompt = None; request.prompt = None;
request.messages.clear(); request.messages.clear();
add_task_list_context(&mut request, self.run.task_list());
let fixed = u32::try_from(estimate_turn_request_tokens(&request)) let fixed = u32::try_from(estimate_turn_request_tokens(&request))
.unwrap_or(u32::MAX); .unwrap_or(u32::MAX);
Some((limit, fixed)) Some((limit, fixed))
@@ -285,6 +288,7 @@ impl ProviderRunCoordinator {
let mut request = profile.request.clone(); let mut request = profile.request.clone();
request.prompt = None; request.prompt = None;
request.messages.clear(); request.messages.clear();
add_task_list_context(&mut request, self.run.task_list());
let persistent_tokens = estimate_turn_request_tokens(&request); let persistent_tokens = estimate_turn_request_tokens(&request);
request.messages = self.run.transcript().to_vec(); request.messages = self.run.transcript().to_vec();
Some(( Some((
@@ -432,6 +436,9 @@ impl ProviderRunCoordinator {
} }
} }
Some(ProviderRunStep::DispatchTools(batch)) => { Some(ProviderRunStep::DispatchTools(batch)) => {
if self.run.task_list().is_none() {
self.run.set_task_list(task_list_from_plan_batch(&batch))?;
}
if !self.checkpoint_or_fail(&mut checkpoint).await? { if !self.checkpoint_or_fail(&mut checkpoint).await? {
return self.terminal_block(); return self.terminal_block();
} }
@@ -500,8 +507,12 @@ impl ProviderRunCoordinator {
else { else {
return false; return false;
}; };
let request = let request = request_for_model_call(
request_for_model_call(profile.request.clone(), call, Some(max_context_tokens)); profile.request.clone(),
call,
Some(max_context_tokens),
self.run.task_list(),
);
// Provider tokenizers and request envelopes differ. Compact before the // Provider tokenizers and request envelopes differ. Compact before the
// estimate reaches the advertised limit, leaving room for that uncertainty. // estimate reaches the advertised limit, leaving room for that uncertainty.
let safe_context_tokens = u64::from(max_context_tokens) * 9 / 10; let safe_context_tokens = u64::from(max_context_tokens) * 9 / 10;
@@ -585,6 +596,7 @@ impl ProviderRunCoordinator {
profile.request.clone(), profile.request.clone(),
&call, &call,
profile.max_context_tokens.or(self.max_context_tokens), profile.max_context_tokens.or(self.max_context_tokens),
self.run.task_list(),
); );
let started_at = Instant::now(); let started_at = Instant::now();
let (attempt_sender, attempt_control) = turn_control(); let (attempt_sender, attempt_control) = turn_control();
@@ -1176,6 +1188,7 @@ fn request_for_model_call(
mut template: TurnRequest, mut template: TurnRequest,
call: &ProviderModelCall, call: &ProviderModelCall,
max_context_tokens: Option<u32>, max_context_tokens: Option<u32>,
task_list: Option<&TaskListSnapshot>,
) -> TurnRequest { ) -> TurnRequest {
template.prompt = None; template.prompt = None;
template.messages = call.messages.clone(); template.messages = call.messages.clone();
@@ -1184,6 +1197,7 @@ fn request_for_model_call(
message message
.truncate_tagged_terminal_output_for_provider_request_with_context(max_context_tokens); .truncate_tagged_terminal_output_for_provider_request_with_context(max_context_tokens);
} }
add_task_list_context(&mut template, task_list);
add_exploration_reminder(&mut template); add_exploration_reminder(&mut template);
template template
} }
@@ -945,6 +945,50 @@ async fn inline_tool_batches_commit_before_the_next_model_boundary() {
)); ));
} }
#[tokio::test]
async fn task_progress_reaches_the_next_model_after_compaction_and_restore() {
use galaxy_agent_core::{TaskListItem, TaskListSnapshot, TaskListStatus};
use crate::ai::runtime::task_progress::{add_task_list_context, initial_task_list, task_list_tool};
let initial = TaskListSnapshot { tasks: vec![TaskListItem {
id: "verify".to_owned(), title: "Build the change".to_owned(), status: TaskListStatus::InProgress,
}] };
let current = TaskListSnapshot { tasks: vec![TaskListItem {
status: TaskListStatus::Completed, ..initial.tasks[0].clone()
}] };
let runtime = Arc::new(ScriptedRuntime::new(vec![
tool_turn_with_calls(vec![ToolCall {
id: "progress".to_owned(),
name: galaxy_agent_core::UPDATE_TASK_LIST_NAME.to_owned(),
arguments: serde_json::to_value(&current).unwrap(),
}]),
answer_turn(),
]));
let mut template = request();
template.tools.push(task_list_tool());
add_task_list_context(&mut template, Some(&initial));
let mut coordinator = ProviderRunCoordinator::from_request(
"run", ProviderRunProfile::new(runtime.clone(), template.clone()), Vec::new(), ProviderRunLimits::default(),
).unwrap();
let (_sender, control) = turn_control();
assert!(matches!(coordinator.drive_until_blocked(control, |_| Ok(())).await.unwrap(), ProviderRunBlock::ReadyToCallModel));
assert_eq!(coordinator.run().task_list(), Some(&current));
let length = coordinator.run().transcript().len();
coordinator.run_mut().compact_transcript_at_model_boundary(length, vec![ConversationMessage {
role: MessageRole::User, content: MessageContent::Text("Prior work summarized.".to_owned()),
}]).unwrap();
coordinator.run_mut().replace_tool_result_archive(Vec::new());
let restored: ProviderRun = serde_json::from_str(&serde_json::to_string(coordinator.run()).unwrap()).unwrap();
restored.validate_restored_state().unwrap();
let mut coordinator = ProviderRunCoordinator::new(restored, BTreeMap::from([
(BASE_PROVIDER_PROFILE.to_owned(), ProviderRunProfile::new(runtime.clone(), template)),
])).unwrap();
let (_sender, control) = turn_control();
coordinator.drive_until_blocked(control, |_| Ok(())).await.unwrap();
assert_eq!(initial_task_list(&runtime.requests()[1]), Some(current));
assert_eq!(runtime.requests()[1].messages.len(), 1);
}
#[tokio::test] #[tokio::test]
async fn recoverable_start_failure_retries_the_same_work_identity() { async fn recoverable_start_failure_retries_the_same_work_identity() {
let mut recoverable = AgentError::new(AgentErrorKind::Transport, "temporary network error"); let mut recoverable = AgentError::new(AgentErrorKind::Transport, "temporary network error");
+15 -2
View File
@@ -15,6 +15,9 @@ use uuid::Uuid;
use warp_multi_agent_api as api; use warp_multi_agent_api as api;
use warp_multi_agent_api::ToolType; use warp_multi_agent_api::ToolType;
use super::task_progress::{
add_task_list_context, task_list_from_api_tasks, task_list_tool, TASK_PROGRESS_GUIDANCE,
};
use crate::ai::agent::api::RequestParams; use crate::ai::agent::api::RequestParams;
use crate::ai::agent::{AIAgentContext, AIAgentInput, MCPContext, UserQueryMode}; use crate::ai::agent::{AIAgentContext, AIAgentInput, MCPContext, UserQueryMode};
use crate::ai::openai::client::OpenAIClientConfig; use crate::ai::openai::client::OpenAIClientConfig;
@@ -277,7 +280,9 @@ fn prepare_rig_turn_for_provider(
// History recall cannot advance a running command. Keeping it in the CLI tool list lets // History recall cannot advance a running command. Keeping it in the CLI tool list lets
// the model spend its monitor turn recalling a prior snapshot instead of scheduling // the model spend its monitor turn recalling a prior snapshot instead of scheduling
// `read_shell_command_output`, so make polling the only inspection path here. // `read_shell_command_output`, so make polling the only inspection path here.
tools.retain(|tool| tool.name != "recall_tool_history"); tools.retain(|tool| {
!matches!(tool.name.as_str(), "recall_tool_history" | "update_task_list")
});
} }
RigRequestMode::CompletedCommandAssessment => { RigRequestMode::CompletedCommandAssessment => {
// The caller deliberately disables tools for the final assessment. The inline history // The caller deliberately disables tools for the final assessment. The inline history
@@ -348,6 +353,8 @@ fn prepare_rig_turn_for_provider(
request.system_prompt = Some(system_prompt); request.system_prompt = Some(system_prompt);
request.tools = tools; request.tools = tools;
request.max_output_tokens = max_output_tokens; request.max_output_tokens = max_output_tokens;
let task_list = task_list_from_api_tasks(&tasks, &task_id);
add_task_list_context(&mut request, task_list.as_ref());
PreparedRigTurn { PreparedRigTurn {
max_context_tokens, max_context_tokens,
@@ -692,6 +699,7 @@ fn tool_definitions(
.into_iter() .into_iter()
.filter(|tool| tool_name_is_supported(&tool.name, &supported)) .filter(|tool| tool_name_is_supported(&tool.name, &supported))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
tools.push(task_list_tool());
let mut mcp_tool_aliases = HashMap::new(); let mut mcp_tool_aliases = HashMap::new();
if !supported.contains(&ToolType::CallMcpTool) { if !supported.contains(&ToolType::CallMcpTool) {
@@ -782,7 +790,9 @@ fn add_tool_title_parameter(schema: &mut serde_json::Value) -> String {
} }
fn tools_are_inline_only(tools: &[ToolDefinition]) -> bool { fn tools_are_inline_only(tools: &[ToolDefinition]) -> bool {
tools.iter().all(|tool| tool.name == "recall_tool_history") tools.iter().all(|tool| {
matches!(tool.name.as_str(), "recall_tool_history" | "update_task_list")
})
} }
const MAX_PROVIDER_TOOL_NAME_BYTES: usize = 64; const MAX_PROVIDER_TOOL_NAME_BYTES: usize = 64;
@@ -850,6 +860,9 @@ fn build_system_prompt(
); );
prompt.push_str(SHELL_EXECUTION_GUIDANCE); prompt.push_str(SHELL_EXECUTION_GUIDANCE);
prompt.push_str(PROGRESS_COMMUNICATION_GUIDANCE); prompt.push_str(PROGRESS_COMMUNICATION_GUIDANCE);
if tools.iter().any(|tool| tool.name == "update_task_list") {
prompt.push_str(TASK_PROGRESS_GUIDANCE);
}
let contexts = inputs.iter().filter_map(AIAgentInput::context).flatten(); let contexts = inputs.iter().filter_map(AIAgentInput::context).flatten();
let mut environment = Vec::new(); let mut environment = Vec::new();
let mut request_time = None; let mut request_time = None;
+17 -2
View File
@@ -270,8 +270,9 @@ fn no_tools_turn_flattens_historical_tool_protocol_messages() {
let prepared = prepare_rig_turn(&config(), params, Vec::new(), Vec::new()); let prepared = prepare_rig_turn(&config(), params, Vec::new(), Vec::new());
assert_eq!(prepared.request.tools.len(), 1); assert_eq!(prepared.request.tools.len(), 2);
assert_eq!(prepared.request.tools[0].name, "recall_tool_history"); assert!(prepared.request.tools.iter().any(|tool| tool.name == "recall_tool_history"));
assert!(prepared.request.tools.iter().any(|tool| tool.name == "update_task_list"));
assert!(prepared assert!(prepared
.request .request
.messages .messages
@@ -499,6 +500,20 @@ fn forced_cli_profile_uses_monitor_prompt_and_tools_for_an_initial_query() {
.tools .tools
.iter() .iter()
.any(|tool| tool.name == "recall_tool_history")); .any(|tool| tool.name == "recall_tool_history"));
assert!(!prepared.request.tools.iter().any(|tool| tool.name == "update_task_list"));
}
#[test]
fn task_progress_is_available_with_explicit_status_guidance_and_ui_title() {
let mut params = RequestParams::new_for_test();
params.input = vec![user_query("Implement and verify the fix")];
let prepared = prepare_rig_turn(&config(), params, vec![ToolType::ReadFiles], Vec::new());
let tool = prepared.request.tools.iter().find(|tool| tool.name == "update_task_list").unwrap();
assert!(tool.input_schema["required"].as_array().unwrap().iter().any(|value| value == "_galaxy_tool_title"));
let prompt = prepared.request.system_prompt.unwrap();
assert!(prompt.contains("## Task List Progress"));
assert!(prompt.contains("reconcile the list before the final response"));
assert!(prompt.contains("Planning or returning a draft does not complete"));
} }
#[test] #[test]
+292
View File
@@ -0,0 +1,292 @@
//! Task state is kept outside the compactable transcript and projected only from
//! explicit, validated model updates. Finishing a response never finishes tasks.
use galaxy_agent_core::{
PendingToolBatch, PendingToolCallState, ProviderRun, ProviderRunState, TaskListItem,
TaskListSnapshot, TaskListStatus, ToolDefinition, ToolResultStatus, TurnRequest,
UPDATE_TASK_LIST_NAME,
};
use warp_multi_agent_api as api;
const SNAPSHOT_START: &str = "\n<galaxy-task-list>\n";
const SNAPSHOT_END: &str = "\n</galaxy-task-list>\n";
const TASK_LIST_REMINDER: &str = "\nKeep the current task list synchronized with the work at meaningful milestones and before finishing. If a step is now done or work has moved to another step, call update_task_list with the evidence-based status. Pending work must remain visible.\n";
pub(super) const TASK_PROGRESS_GUIDANCE: &str = "\n## Task List Progress\nUse `update_task_list` to keep the visible checklist current throughout multi-step work. It accepts the complete list, with stable task IDs, titles, and pending/in_progress/completed statuses. Keep at most one task in progress and place it before pending tasks. After creating a plan, use its checklist and IDs from the current task list; do not create a competing list. Update when starting the next step, completing a meaningful step, changing scope, or discovering a blocker, and reconcile the list before the final response. Preserve unfinished tasks when blocked or waiting for a decision; explain the blocker in the task title if needed. Mark completed only when the work and required verification actually succeeded. Planning or returning a draft does not complete its implementation tasks. Do not recreate an unchanged list, update after every poll, or spend turns just restating progress. Tool panes show work; task updates do not require prose announcements. CLI command monitoring must continue using its advertised monitoring tools.\n";
pub(super) fn task_list_tool() -> ToolDefinition {
ToolDefinition {
name: UPDATE_TASK_LIST_NAME.to_owned(),
description: "Create or update the visible task list for this conversation. Send the complete list with stable IDs, retaining completed and unfinished work. Update at meaningful milestones, when the current step changes, and before finishing; mark completed only after the work and required checks succeed. This records task progress only and does not execute the tasks or edit the plan document.".to_owned(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"tasks": {
"type": "array", "minItems": 1, "maxItems": 50,
"items": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 200},
"title": {"type": "string", "minLength": 1, "maxLength": 1000},
"status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
},
"required": ["id", "title", "status"],
"additionalProperties": false
}
}
},
"required": ["tasks"],
"additionalProperties": false
}),
}
}
pub(crate) fn task_list_from_api_tasks(
tasks: &[api::Task],
root_task_id: &str,
) -> Option<TaskListSnapshot> {
use api::message::update_todos::Operation;
let task = tasks.iter().find(|task| task.id == root_task_id)?;
let mut pending = Vec::<api::TodoItem>::new();
let mut completed = Vec::<api::TodoItem>::new();
for message in &task.messages {
let Some(api::message::Message::UpdateTodos(update)) = &message.message else {
continue;
};
let Some(operation) = update.operation.as_ref() else {
continue;
};
match operation {
Operation::CreateTodoList(create) => {
pending = create.initial_todos.clone();
completed.clear();
}
Operation::UpdatePendingTodos(update) => {
pending = update.updated_pending_todos.clone();
}
Operation::MarkTodosCompleted(mark) => {
for id in &mark.todo_ids {
if let Some(index) = pending.iter().position(|task| task.id == *id) {
let item = pending.remove(index);
if let Some(old) = completed.iter_mut().find(|task| task.id == *id) {
*old = item;
} else {
completed.push(item);
}
}
}
}
}
}
let snapshot = TaskListSnapshot {
tasks: completed
.into_iter()
.map(|task| task_item(task, TaskListStatus::Completed))
.chain(pending.into_iter().enumerate().map(|(index, task)| {
task_item(
task,
if index == 0 {
TaskListStatus::InProgress
} else {
TaskListStatus::Pending
},
)
}))
.collect(),
};
snapshot.validate().ok()?;
Some(snapshot)
}
fn task_item(task: api::TodoItem, status: TaskListStatus) -> TaskListItem {
TaskListItem {
id: task.id,
title: task.title,
status,
}
}
pub(super) fn initial_task_list(request: &TurnRequest) -> Option<TaskListSnapshot> {
let prompt = request.system_prompt.as_deref()?;
let (_, snapshot) = prompt.rsplit_once(SNAPSHOT_START)?;
let (snapshot, _) = snapshot.split_once(SNAPSHOT_END)?;
let snapshot: TaskListSnapshot = serde_json::from_str(snapshot).ok()?;
snapshot.validate().ok()?;
Some(snapshot)
}
pub(super) fn add_task_list_context(request: &mut TurnRequest, snapshot: Option<&TaskListSnapshot>) {
let Some(prompt) = request.system_prompt.as_mut() else {
return;
};
if prompt.ends_with(TASK_LIST_REMINDER) {
prompt.truncate(prompt.len() - TASK_LIST_REMINDER.len());
}
if let Some(start) = prompt.rfind(SNAPSHOT_START) {
if let Some(end) = prompt[start..].find(SNAPSHOT_END) {
prompt.replace_range(start..start + end + SNAPSHOT_END.len(), "");
}
}
if let Some(snapshot) = snapshot {
prompt.push_str(SNAPSHOT_START);
prompt.push_str(&serde_json::to_string(snapshot).expect("serializable task list"));
prompt.push_str(SNAPSHOT_END);
}
if request
.tools
.iter()
.any(|tool| tool.name == UPDATE_TASK_LIST_NAME)
{
prompt.push_str(TASK_LIST_REMINDER);
}
}
/// Preserve the existing plan checklist behavior, with stable IDs shared by the
/// UI and the next provider request. Rejected tools cannot create task lists.
pub(super) fn task_list_from_plan_batch(batch: &PendingToolBatch) -> Option<TaskListSnapshot> {
let plan_call = batch.calls.iter().find(|pending| {
matches!(pending.call.name.as_str(), "create_plan" | "create_documents")
&& !matches!(
&pending.state,
PendingToolCallState::Resolved { result }
if result.status != galaxy_agent_core::ToolResultStatus::Success
)
})?;
let content = plan_call
.call
.arguments
.get("documents")?
.as_array()?
.first()?
.get("content")?
.as_str()?;
let mut lines = content.lines();
lines.find(|line| matches!(line.trim(), "## Tasks" | "## Implementation Tasks"))?;
let tasks = lines
.take_while(|line| !line.trim().starts_with("## "))
.filter_map(|line| line.trim().strip_prefix("- [ ]").map(str::trim))
.filter(|item| !item.is_empty())
.take(50)
.enumerate()
.map(|(index, item)| TaskListItem {
id: format!("plan-{}-{index}", plan_call.call.id),
title: item
.split_once(" - ")
.map_or(item, |(title, _)| title)
.trim()
.chars()
.take(1_000)
.collect(),
status: TaskListStatus::Pending,
})
.collect::<Vec<_>>();
if tasks.len() < 2 {
return None;
}
let snapshot = TaskListSnapshot { tasks };
snapshot.validate().ok()?;
Some(snapshot)
}
fn todo_item(task: &TaskListItem) -> api::TodoItem {
api::TodoItem {
id: task.id.clone(),
title: task.title.clone(),
description: task.title.clone(),
}
}
pub(super) fn explicit_task_updates(batch: &PendingToolBatch) -> Vec<TaskListSnapshot> {
batch
.calls
.iter()
.filter_map(|pending| {
if pending.call.name != UPDATE_TASK_LIST_NAME {
return None;
}
let PendingToolCallState::Resolved { result } = &pending.state else {
return None;
};
if result.status != ToolResultStatus::Success {
return None;
}
galaxy_agent_core::task_list_from_tool_result(&result.content)
})
.collect()
}
pub(crate) fn restored_task_list(run: &ProviderRun) -> Option<TaskListSnapshot> {
// A mixed batch can have projected an inline update before the other tools
// finish. Conversely an inline-only batch can commit during normalization
// before its UI projection was ever persisted. Reconcile both crash windows.
if let ProviderRunState::AwaitingTools { batch } = run.state() {
if let Some(snapshot) = explicit_task_updates(batch).pop() {
return Some(snapshot);
}
}
run.task_list().cloned()
}
pub(super) fn todo_operations(
previous: Option<&TaskListSnapshot>,
snapshot: &TaskListSnapshot,
) -> Vec<api::message::update_todos::Operation> {
use api::message::update_todos::Operation;
if previous == Some(snapshot) {
return Vec::new();
}
// Existing completed items are immutable in the transcript protocol. A new
// scope or reopening a completed item therefore starts a replacement list.
let replace = previous.is_none_or(|previous| {
previous.tasks.iter().any(|old| {
old.status == TaskListStatus::Completed
&& !snapshot.tasks.iter().any(|task| {
task.id == old.id
&& task.title == old.title
&& task.status == TaskListStatus::Completed
})
})
});
let already_completed = |id: &str| {
!replace
&& previous.is_some_and(|previous| {
previous
.tasks
.iter()
.any(|task| task.id == id && task.status == TaskListStatus::Completed)
})
};
let todos = snapshot
.tasks
.iter()
.filter(|task| !already_completed(&task.id))
.map(todo_item)
.collect::<Vec<_>>();
let mut operations = vec![if replace {
Operation::CreateTodoList(api::CreateTodoList {
initial_todos: todos,
})
} else {
Operation::UpdatePendingTodos(api::UpdatePendingTodos {
updated_pending_todos: todos,
})
}];
let todo_ids = snapshot
.tasks
.iter()
.filter(|task| task.status == TaskListStatus::Completed && !already_completed(&task.id))
.map(|task| task.id.clone())
.collect::<Vec<_>>();
if !todo_ids.is_empty() {
operations.push(Operation::MarkTodosCompleted(api::MarkTodosCompleted {
todo_ids,
}));
}
operations
}
#[cfg(test)]
#[path = "task_progress_tests.rs"]
mod tests;
+116
View File
@@ -0,0 +1,116 @@
use super::*;
fn snapshot(tasks: &[(&str, TaskListStatus)]) -> TaskListSnapshot {
TaskListSnapshot {
tasks: tasks.iter().map(|(id, status)| TaskListItem {
id: (*id).to_owned(),
title: format!("Task {id}"),
status: *status,
}).collect(),
}
}
fn task_with_operations(operations: Vec<api::message::update_todos::Operation>) -> api::Task {
api::Task {
id: "root".to_owned(),
messages: operations.into_iter().map(|operation| api::Message {
message: Some(api::message::Message::UpdateTodos(api::message::UpdateTodos {
operation: Some(operation),
})),
..Default::default()
}).collect(),
..Default::default()
}
}
#[test]
fn task_status_updates_reuse_the_list_and_survive_a_followup_request() {
use TaskListStatus::{Completed, InProgress, Pending};
use api::message::update_todos::Operation;
let initial = snapshot(&[("inspect", InProgress), ("implement", Pending), ("verify", Pending)]);
let next = snapshot(&[("inspect", Completed), ("implement", InProgress), ("verify", Pending)]);
let updates = todo_operations(Some(&initial), &next);
assert!(matches!(&updates[0], Operation::UpdatePendingTodos(_)));
assert!(matches!(&updates[1], Operation::MarkTodosCompleted(mark) if mark.todo_ids == ["inspect"]));
let mut operations = todo_operations(None, &initial);
operations.extend(updates);
let root = task_with_operations(operations);
let unrelated_child = api::Task { id: "child".to_owned(), ..Default::default() };
assert_eq!(task_list_from_api_tasks(&[unrelated_child, root], "root"), Some(next.clone()));
assert!(todo_operations(Some(&next), &next).is_empty());
let finished = snapshot(&[("inspect", Completed), ("implement", Completed), ("verify", Completed)]);
let operations = todo_operations(Some(&next), &finished);
let Operation::UpdatePendingTodos(update) = &operations[0] else {
panic!("normal progress preserves the existing checklist");
};
assert_eq!(update.updated_pending_todos.len(), 2);
assert!(!update.updated_pending_todos.iter().any(|task| task.id == "inspect"));
}
#[test]
fn reopening_completed_work_replaces_the_protocol_list_without_losing_ids() {
use TaskListStatus::{Completed, InProgress};
let old = snapshot(&[("inspect", Completed), ("verify", Completed)]);
let reopened = snapshot(&[("inspect", Completed), ("verify", InProgress)]);
let operations = todo_operations(Some(&old), &reopened);
assert!(matches!(&operations[0], api::message::update_todos::Operation::CreateTodoList(_)));
let root = task_with_operations(operations);
assert_eq!(task_list_from_api_tasks(&[root], "root"), Some(reopened));
}
#[test]
fn redelivered_progress_cannot_duplicate_completed_items_in_followup_state() {
use TaskListStatus::{Completed, InProgress, Pending};
let initial = snapshot(&[("inspect", InProgress), ("verify", Pending)]);
let next = snapshot(&[("inspect", Completed), ("verify", InProgress)]);
let update = todo_operations(Some(&initial), &next);
let mut operations = todo_operations(None, &initial);
operations.extend(update.clone());
operations.extend(update);
assert_eq!(task_list_from_api_tasks(&[task_with_operations(operations)], "root"), Some(next));
}
#[test]
fn current_snapshot_replaces_stale_prompt_state_without_accumulating_reminders() {
let initial = snapshot(&[("inspect", TaskListStatus::InProgress)]);
let current = snapshot(&[("inspect", TaskListStatus::Completed)]);
let mut request = TurnRequest::new("model", Vec::new());
request.system_prompt = Some("Follow user instructions.\n".to_owned());
request.tools = vec![task_list_tool()];
add_task_list_context(&mut request, Some(&initial));
assert_eq!(initial_task_list(&request), Some(initial));
add_task_list_context(&mut request, Some(&current));
let prompt = request.system_prompt.clone();
add_task_list_context(&mut request, Some(&current));
assert_eq!(request.system_prompt, prompt);
assert_eq!(initial_task_list(&request), Some(current));
let prompt = request.system_prompt.unwrap();
assert_eq!(prompt.matches(SNAPSHOT_START).count(), 1);
assert_eq!(prompt.matches(TASK_LIST_REMINDER).count(), 1);
assert!(prompt.starts_with("Follow user instructions."));
}
#[test]
fn rejected_plan_creation_cannot_seed_visible_or_durable_tasks() {
let batch = PendingToolBatch {
work_id: galaxy_agent_core::ExternalWorkId {
run_id: galaxy_agent_core::ProviderRunId::new("run"),
epoch: galaxy_agent_core::RunEpoch::new(1),
},
calls: vec![galaxy_agent_core::PendingToolCall {
call: galaxy_agent_core::ToolCall {
id: "plan".to_owned(),
name: "create_plan".to_owned(),
arguments: serde_json::json!({"documents": [{"content": "## Tasks\n- [ ] Inspect\n- [ ] Verify"}]}),
},
state: PendingToolCallState::Resolved { result: galaxy_agent_core::ToolResult {
call_id: "plan".to_owned(),
content: "Not advertised".to_owned(),
status: galaxy_agent_core::ToolResultStatus::Error,
}},
}],
};
assert!(task_list_from_plan_batch(&batch).is_none());
}
+125 -21
View File
@@ -41,7 +41,7 @@ use super::drive_helpers::{
use super::empty_trash_confirmation_dialog::{ use super::empty_trash_confirmation_dialog::{
EmptyTrashConfirmationDialog, EmptyTrashConfirmationEvent, EmptyTrashConfirmationDialog, EmptyTrashConfirmationEvent,
}; };
use super::folders::CloudFolder; use super::folders::{CloudFolder, CloudFolderModel, FolderId};
use super::items::ai_fact_collection::WarpDriveAIFactCollection; use super::items::ai_fact_collection::WarpDriveAIFactCollection;
use super::items::item::{tools_panel_menu_direction, ItemStates, WarpDriveRow}; use super::items::item::{tools_panel_menu_direction, ItemStates, WarpDriveRow};
use super::items::mcp_server_collection::WarpDriveMCPServerCollection; use super::items::mcp_server_collection::WarpDriveMCPServerCollection;
@@ -69,7 +69,7 @@ use crate::editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions};
use crate::env_vars::CloudEnvVarCollection; use crate::env_vars::CloudEnvVarCollection;
use crate::features::FeatureFlag; use crate::features::FeatureFlag;
use crate::local_object_repository::{local_owner, LocalObjectRepository}; use crate::local_object_repository::{local_owner, LocalObjectRepository};
use crate::menu::{Event, Menu, MenuItem, MenuItemFields}; use crate::menu::{Event, Menu, MenuItem, MenuItemFields, SubMenu};
use crate::network::NetworkStatus; use crate::network::NetworkStatus;
use crate::notebooks::CloudNotebookModel; use crate::notebooks::CloudNotebookModel;
use crate::server::cloud_objects::update_manager::{ use crate::server::cloud_objects::update_manager::{
@@ -1060,6 +1060,21 @@ impl DriveIndex {
false false
} }
fn move_operation_allowed(
&self,
cloud_object_type_and_id: &CloudObjectTypeAndId,
app: &AppContext,
) -> bool {
let cloud_model = CloudModel::as_ref(app);
if let Some(object) = cloud_model.get_by_uid(&cloud_object_type_and_id.uid()) {
if object.permissions().owner == local_owner() {
return !object.is_trashed(cloud_model)
&& !object.metadata().has_pending_online_only_change();
}
}
self.online_only_operation_allowed(cloud_object_type_and_id, app)
}
fn is_online(&self, app: &AppContext) -> bool { fn is_online(&self, app: &AppContext) -> bool {
NetworkStatus::as_ref(app).is_online() NetworkStatus::as_ref(app).is_online()
} }
@@ -2685,7 +2700,7 @@ impl DriveIndex {
// If the share dialog is open, we don't want to open the menu for the same object. // If the share dialog is open, we don't want to open the menu for the same object.
let menu_open = let menu_open =
self.menu_object_id_if_open == Some(warp_drive_item_id) && !share_dialog_open; self.menu_object_id_if_open == Some(warp_drive_item_id) && !share_dialog_open;
let can_move = self.online_only_operation_allowed(&row_object_id, app) let can_move = self.move_operation_allowed(&row_object_id, app)
&& matches!(self.index_variant, DriveIndexVariant::MainIndex) && matches!(self.index_variant, DriveIndexVariant::MainIndex)
&& access_level.can_move_drive(); && access_level.can_move_drive();
let mut is_focused = false; let mut is_focused = false;
@@ -3239,12 +3254,15 @@ impl DriveIndex {
) { ) {
// TODO: we need to tell the user why 'move' is not working. // TODO: we need to tell the user why 'move' is not working.
let is_drop_target_valid = match new_location { let is_drop_target_valid = match new_location {
CloudObjectLocation::Folder(folder_id) => self.online_only_operation_allowed( CloudObjectLocation::Folder(folder_id) => {
&CloudObjectTypeAndId::from_id_and_type(folder_id, ObjectType::Folder), Self::is_local_folder(&folder_id, ctx)
ctx, || self.online_only_operation_allowed(
), &CloudObjectTypeAndId::from_id_and_type(folder_id, ObjectType::Folder),
CloudObjectLocation::Space(_) => self.is_online(ctx), ctx,
CloudObjectLocation::Trash => self.is_online(ctx), )
}
CloudObjectLocation::Space(space) => space == Space::Personal || self.is_online(ctx),
CloudObjectLocation::Trash => true,
}; };
if is_drop_target_valid { if is_drop_target_valid {
@@ -3275,23 +3293,37 @@ impl DriveIndex {
self.current_drop_target = None; self.current_drop_target = None;
ctx.notify(); ctx.notify();
if !self.online_only_operation_allowed(cloud_object_type_and_id, ctx) { if !self.move_operation_allowed(cloud_object_type_and_id, ctx) {
return; return;
} }
let cloud_model = CloudModel::handle(ctx); let cloud_model = CloudModel::handle(ctx);
let is_local = cloud_model
.as_ref(ctx)
.get_by_uid(&cloud_object_type_and_id.uid())
.is_some_and(|object| object.permissions().owner == local_owner());
let can_move = if is_local {
LocalObjectRepository::can_move_object_to_location(
*cloud_object_type_and_id,
new_location,
ctx,
)
} else {
cloud_model.as_ref(ctx).can_move_object_to_location(
&cloud_object_type_and_id.uid(),
new_location,
ctx,
)
};
// Only proceed if we can move the object to this location AND the operation results in a change. // Only proceed if we can move the object to this location AND the operation results in a change.
// Even though an object technically can be moved to its current location (can_move_object_to_location returns true) // Even though an object technically can be moved to its current location (can_move_object_to_location returns true)
// we do not need to do any of the update logic that follows. // we do not need to do any of the update logic that follows.
if !cloud_model.as_ref(ctx).can_move_object_to_location( if !can_move
&cloud_object_type_and_id.uid(), || cloud_model
new_location, .as_ref(ctx)
ctx, .object_location(&cloud_object_type_and_id.uid(), ctx)
) || cloud_model .is_none_or(|location| location == new_location)
.as_ref(ctx)
.object_location(&cloud_object_type_and_id.uid(), ctx)
.is_none_or(|location| location == new_location)
{ {
return; return;
} }
@@ -3329,9 +3361,15 @@ impl DriveIndex {
} }
// Otherwise allow object move to go through. // Otherwise allow object move to go through.
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| { if is_local {
update_manager.move_object_to_location(*cloud_object_type_and_id, new_location, ctx); LocalObjectRepository::handle(ctx).update(ctx, |repository, ctx| {
}); repository.move_object_to_location(*cloud_object_type_and_id, new_location, ctx);
});
} else {
UpdateManager::handle(ctx).update(ctx, move |update_manager, ctx| {
update_manager.move_object_to_location(*cloud_object_type_and_id, new_location, ctx);
});
}
match new_location { match new_location {
CloudObjectLocation::Space(space) => self.open_section_of_space(space), CloudObjectLocation::Space(space) => self.open_section_of_space(space),
@@ -4873,6 +4911,13 @@ impl DriveIndex {
} }
} }
if self.move_operation_allowed(cloud_object_type_and_id, app) && access_level.can_move_drive()
{
if let Some(move_menu) = self.local_move_menu_item(*cloud_object_type_and_id, app) {
menu_items.push(move_menu);
}
}
if can_trash && (!FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash()) { if can_trash && (!FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash()) {
menu_items.push( menu_items.push(
MenuItemFields::new("Trash") MenuItemFields::new("Trash")
@@ -4887,6 +4932,65 @@ impl DriveIndex {
menu_items menu_items
} }
fn local_move_menu_item(
&self,
object_id: CloudObjectTypeAndId,
app: &AppContext,
) -> Option<MenuItem<DriveIndexAction>> {
let cloud_model = CloudModel::as_ref(app);
let object = cloud_model.get_by_uid(&object_id.uid())?;
if object.permissions().owner != local_owner() {
return None;
}
let mut destinations = Vec::new();
if object.metadata().folder_id.is_some() {
destinations.push((
"Personal (top level)".to_string(),
CloudObjectLocation::Space(Space::Personal),
));
}
destinations.extend(
cloud_model
.get_all_objects_of_type::<FolderId, CloudFolderModel>()
.filter(|folder| object.metadata().folder_id != Some(folder.id))
.filter(|folder| {
LocalObjectRepository::can_move_object_to_location(
object_id,
CloudObjectLocation::Folder(folder.id),
app,
)
})
.map(|folder| {
(
format!("{} / {}", folder.breadcrumbs(app), folder.model().name),
CloudObjectLocation::Folder(folder.id),
)
})
.sorted_by(|(left, _), (right, _)| left.cmp(right)),
);
if destinations.is_empty() {
return None;
}
let items = destinations
.into_iter()
.map(|(label, location)| {
MenuItemFields::new(label)
.with_on_select_action(DriveIndexAction::DropIndexItem {
cloud_object_type_and_id: object_id,
drop_target_location: location,
})
.with_icon(Icon::Folder)
.into_item()
})
.collect();
Some(MenuItem::Submenu {
fields: MenuItemFields::new_submenu("Move to").with_icon(Icon::Move),
menu: SubMenu::new(items),
})
}
/// Builder for a menu item to open a Warp Drive object in a pane. The icon and label depend /// Builder for a menu item to open a Warp Drive object in a pane. The icon and label depend
/// on whether the object is editable or not. /// on whether the object is editable or not.
/// ///
+66 -1
View File
@@ -11,10 +11,12 @@ use crate::cloud_object::model::actions::ObjectActions;
use crate::cloud_object::model::persistence::CloudModel; use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::model::view::CloudViewModel; use crate::cloud_object::model::view::CloudViewModel;
use crate::cloud_object::{ use crate::cloud_object::{
CloudObjectSyncStatus, ObjectIdType, ObjectType, Owner, ServerCreationInfo, Space, CloudObjectLocation, CloudObjectSyncStatus, ObjectIdType, ObjectType, Owner,
ServerCreationInfo, Space,
}; };
use crate::drive::items::WarpDriveItemId; use crate::drive::items::WarpDriveItemId;
use crate::drive::CloudObjectTypeAndId; use crate::drive::CloudObjectTypeAndId;
use crate::local_object_repository::LocalObjectRepository;
use crate::menu::MenuItem; use crate::menu::MenuItem;
use crate::network::NetworkStatus; use crate::network::NetworkStatus;
use crate::notebooks::{CloudNotebook, CloudNotebookModel}; use crate::notebooks::{CloudNotebook, CloudNotebookModel};
@@ -125,6 +127,69 @@ fn label_for_menu_item(item: &MenuItem<DriveIndexAction>) -> &str {
} }
} }
#[test]
fn local_drive_objects_can_move_into_and_out_of_plans_offline() {
App::test(ASSETS, |mut app| async move {
initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let plans_id = SyncId::ClientId(ClientId::new());
let notebook_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(plans_id, "Plans".to_string(), None, ctx);
repository.create_notebook_with_id(
notebook_id,
None,
CloudNotebookModel::default(),
ctx,
);
});
NetworkStatus::handle(&app).update(&mut app, |network_status, ctx| {
network_status.reachability_changed(false, ctx);
});
let index = create_index(&mut app);
let object_id = CloudObjectTypeAndId::Notebook(notebook_id);
index.update(&mut app, |index, ctx| {
assert!(index.move_operation_allowed(&object_id, ctx));
index.update_drop_target_location(CloudObjectLocation::Folder(plans_id), ctx);
assert_eq!(
index.current_drop_target,
Some(CloudObjectLocation::Folder(plans_id)),
);
let Some(MenuItem::Submenu { fields, menu }) =
index.local_move_menu_item(object_id, ctx)
else {
panic!("expected a local Move to submenu");
};
assert_eq!(fields.label(), "Move to");
assert_eq!(label_for_menu_item(&menu.items()[0]), "Personal / Plans");
index.move_object(&object_id, CloudObjectLocation::Folder(plans_id), ctx);
assert_eq!(
CloudModel::as_ref(ctx)
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id,
Some(plans_id),
);
let Some(MenuItem::Submenu { menu, .. }) = index.local_move_menu_item(object_id, ctx)
else {
panic!("expected moving the notebook back to the top level to be offered");
};
assert_eq!(label_for_menu_item(&menu.items()[0]), "Personal (top level)");
index.move_object(&object_id, CloudObjectLocation::Space(Space::Personal), ctx);
assert!(CloudModel::as_ref(ctx)
.get_notebook(&notebook_id)
.unwrap()
.metadata
.folder_id
.is_none());
});
});
}
#[test] #[test]
fn test_retry_menu_item_visibility() { fn test_retry_menu_item_visibility() {
App::test(ASSETS, |mut app| async move { App::test(ASSETS, |mut app| async move {
+19 -5
View File
@@ -27,6 +27,7 @@ use crate::drive::index::{
use crate::drive::items::WarpDriveItem; use crate::drive::items::WarpDriveItem;
use crate::drive::panel::WARP_DRIVE_POSITION_ID; use crate::drive::panel::WARP_DRIVE_POSITION_ID;
use crate::drive::CloudObjectTypeAndId; use crate::drive::CloudObjectTypeAndId;
use crate::local_object_repository::{local_owner, LocalObjectRepository};
use crate::menu::Menu; use crate::menu::Menu;
use crate::ui_components::blended_colors; use crate::ui_components::blended_colors;
use crate::ui_components::icons::{Icon, ICON_DIMENSIONS}; use crate::ui_components::icons::{Icon, ICON_DIMENSIONS};
@@ -826,11 +827,24 @@ impl UiComponent for WarpDriveRow<'_> {
return AcceptedByDropTarget::No; return AcceptedByDropTarget::No;
}; };
let cloud_model = CloudModel::handle(app); let cloud_model = CloudModel::handle(app);
if cloud_model.as_ref(app).can_move_object_to_location( let is_local = cloud_model
&item.uid(), .as_ref(app)
*location, .get_by_uid(&item.uid())
app, .is_some_and(|object| {
) { object.permissions().owner == local_owner()
});
let can_move = if is_local {
LocalObjectRepository::can_move_object_to_location(
item, *location, app,
)
} else {
cloud_model.as_ref(app).can_move_object_to_location(
&item.uid(),
*location,
app,
)
};
if can_move {
AcceptedByDropTarget::Yes AcceptedByDropTarget::Yes
} else { } else {
AcceptedByDropTarget::No AcceptedByDropTarget::No
+1
View File
@@ -2079,6 +2079,7 @@ pub(crate) fn initialize_app(
// AIDocumentModel subscribes to UpdateManager so that it can be notified when notebooks are created on the server. // AIDocumentModel subscribes to UpdateManager so that it can be notified when notebooks are created on the server.
ctx.add_singleton_model(AIDocumentModel::new); ctx.add_singleton_model(AIDocumentModel::new);
ctx.add_singleton_model(ai::document::plan_archival::PlanArchivalModel::new);
// AgentConversationsModel subscribes to UpdateManager for RTC task updates. // AgentConversationsModel subscribes to UpdateManager for RTC task updates.
ctx.add_singleton_model(AgentConversationsModel::new); ctx.add_singleton_model(AgentConversationsModel::new);
+101 -3
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::sync::mpsc::SyncSender; use std::sync::mpsc::SyncSender;
use chrono::Utc; use chrono::Utc;
@@ -13,7 +14,7 @@ use crate::ai::mcp::templatable::{
}; };
use crate::auth::UserUid; use crate::auth::UserUid;
use crate::cloud_object::model::generic_string_model::GenericStringObjectId; use crate::cloud_object::model::generic_string_model::GenericStringObjectId;
use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent}; use crate::cloud_object::model::persistence::{CloudModel, CloudModelEvent, UpdateSource};
use crate::cloud_object::{ use crate::cloud_object::{
CloudObject, CloudObjectLocation, CloudObjectMetadata, CloudObjectPermissions, CloudObject, CloudObjectLocation, CloudObjectMetadata, CloudObjectPermissions,
CloudObjectStatuses, CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat, CloudObjectStatuses, CloudObjectSyncStatus, GenericCloudObject, GenericStringObjectFormat,
@@ -50,6 +51,7 @@ pub enum LocalObjectRepositoryEvent {
Rules, Rules,
ExecutionProfiles { id: Option<SyncId> }, ExecutionProfiles { id: Option<SyncId> },
Notebooks { id: Option<SyncId> }, Notebooks { id: Option<SyncId> },
NotebookContentEdited { id: SyncId },
Workflows { id: Option<SyncId> }, Workflows { id: Option<SyncId> },
} }
@@ -219,6 +221,95 @@ impl LocalObjectRepository {
CloudModel::as_ref(app).get_folder(id).cloned() CloudModel::as_ref(app).get_folder(id).cloned()
} }
/// Local moves never require a server ID, connectivity, or a cloud ownership transfer.
pub fn can_move_object_to_location(
object_id: CloudObjectTypeAndId,
new_location: CloudObjectLocation,
app: &AppContext,
) -> bool {
let cloud_model = CloudModel::as_ref(app);
let Some(object) = cloud_model.get_by_uid(&object_id.uid()) else {
return false;
};
if object.permissions().owner != local_owner() || object.is_trashed(cloud_model) {
return false;
}
match new_location {
CloudObjectLocation::Space(space) => space == Space::Personal,
CloudObjectLocation::Trash => true,
CloudObjectLocation::Folder(folder_id) => {
let mut ancestor_id = Some(folder_id);
let mut visited = HashSet::new();
while let Some(id) = ancestor_id {
if object_id == CloudObjectTypeAndId::Folder(id) || !visited.insert(id) {
return false;
}
let Some(folder) = cloud_model.get_folder(&id) else {
return false;
};
if folder.permissions.owner != local_owner()
|| folder.metadata.trashed_ts.is_some()
{
return false;
}
ancestor_id = folder.metadata.folder_id;
}
true
}
}
}
/// Reparent a local object and persist its metadata without enqueuing server work.
pub fn move_object_to_location(
&mut self,
object_id: CloudObjectTypeAndId,
new_location: CloudObjectLocation,
ctx: &mut ModelContext<Self>,
) -> bool {
if !Self::can_move_object_to_location(object_id, new_location, ctx) {
return false;
}
let event = CloudModel::handle(ctx).update(ctx, |cloud_model, ctx| {
let object = cloud_model.get_mut_by_uid(&object_id.uid())?;
let from_folder = object.metadata().folder_id;
let to_folder = match new_location {
CloudObjectLocation::Space(_) => None,
CloudObjectLocation::Folder(folder_id) => Some(folder_id),
CloudObjectLocation::Trash => from_folder,
};
let event = if new_location == CloudObjectLocation::Trash {
object.metadata_mut().trashed_ts = Some(ServerTimestamp::new(Utc::now()));
CloudModelEvent::ObjectTrashed {
type_and_id: object_id,
source: UpdateSource::Local,
}
} else {
if from_folder == to_folder {
return None;
}
object.metadata_mut().folder_id = to_folder;
CloudModelEvent::ObjectMoved {
type_and_id: object_id,
source: UpdateSource::Local,
from_folder,
to_folder,
}
};
set_locally_saved_metadata(object.metadata_mut());
let persistence_event = object.upsert_event();
ctx.emit(event);
ctx.notify();
Some(persistence_event)
});
let Some(event) = event else {
return false;
};
self.save(event);
true
}
pub fn create_folder_with_id( pub fn create_folder_with_id(
&mut self, &mut self,
id: SyncId, id: SyncId,
@@ -383,7 +474,10 @@ impl LocalObjectRepository {
let Some(notebook) = self.notebook(&id, ctx) else { let Some(notebook) = self.notebook(&id, ctx) else {
return false; return false;
}; };
self.update_notebook( if notebook.model().data == data {
return true;
}
let updated = self.update_notebook(
id, id,
CloudNotebookModel { CloudNotebookModel {
title: notebook.model().title.clone(), title: notebook.model().title.clone(),
@@ -392,7 +486,11 @@ impl LocalObjectRepository {
conversation_id: notebook.model().conversation_id.clone(), conversation_id: notebook.model().conversation_id.clone(),
}, },
ctx, ctx,
) );
if updated {
ctx.emit(LocalObjectRepositoryEvent::NotebookContentEdited { id });
}
updated
} }
pub fn update_notebook_title( pub fn update_notebook_title(
+263
View File
@@ -172,6 +172,269 @@ fn create_update_and_delete_notebook_are_local_and_persisted() {
}); });
} }
#[test]
fn notebook_content_edit_events_exclude_title_edits_and_unchanged_autosaves() {
App::test((), |mut app| async move {
let receiver = initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(
id,
None,
CloudNotebookModel {
title: "Plan".to_string(),
data: "Initial content".to_string(),
..Default::default()
},
ctx,
);
});
receiver.recv().unwrap();
let (edit_sender, edits) = sync_channel(10);
app.update(|ctx| {
ctx.subscribe_to_model(&repository, move |_, event, _| {
if let LocalObjectRepositoryEvent::NotebookContentEdited { id } = event {
edit_sender.send(*id).unwrap();
}
});
});
assert!(repository.update(&mut app, |repository, ctx| {
repository.update_notebook_title(id, "Renamed plan".to_string(), ctx)
}));
receiver.recv().unwrap();
assert!(edits.try_recv().is_err());
assert!(repository.update(&mut app, |repository, ctx| {
repository.update_notebook_data(id, "Initial content".to_string(), ctx)
}));
assert!(receiver.try_recv().is_err());
assert!(edits.try_recv().is_err());
assert!(repository.update(&mut app, |repository, ctx| {
repository.update_notebook_data(id, "Changed content".to_string(), ctx)
}));
assert!(matches!(
receiver.recv().unwrap(),
ModelEvent::UpsertNotebook { notebook } if notebook.model().data == "Changed content"
));
assert_eq!(edits.try_recv().unwrap(), id);
assert!(edits.try_recv().is_err());
assert!(!repository.update(&mut app, |repository, ctx| {
repository.update_notebook_data(
SyncId::ClientId(ClientId::new()),
"Missing notebook".to_string(),
ctx,
)
}));
assert!(receiver.try_recv().is_err());
assert!(edits.try_recv().is_err());
});
}
#[test]
fn move_local_plan_between_folders_and_root_is_persisted() {
App::test((), |mut app| async move {
let receiver = initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let plans_id = SyncId::ClientId(ClientId::new());
let projects_id = SyncId::ClientId(ClientId::new());
let notebook_id = SyncId::ClientId(ClientId::new());
let document_id = crate::ai::document::ai_document_model::AIDocumentId::new();
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(plans_id, "Plans".to_string(), None, ctx);
repository.create_folder_with_id(projects_id, "Projects".to_string(), None, ctx);
repository.create_notebook_with_id(
notebook_id,
Some(plans_id),
CloudNotebookModel {
title: "Release plan".to_string(),
data: "- [ ] Ship the release".to_string(),
ai_document_id: Some(document_id),
conversation_id: None,
},
ctx,
);
});
for _ in 0..3 {
receiver.recv().unwrap();
}
for (location, expected_parent) in [
(CloudObjectLocation::Folder(projects_id), Some(projects_id)),
(CloudObjectLocation::Space(Space::Personal), None),
(CloudObjectLocation::Folder(plans_id), Some(plans_id)),
] {
assert!(repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(notebook_id),
location,
ctx,
)
}));
let ModelEvent::UpsertNotebook { notebook } = receiver.recv().unwrap() else {
panic!("expected the moved plan to be persisted locally");
};
assert_eq!(notebook.id, notebook_id);
assert_eq!(notebook.metadata.folder_id, expected_parent);
assert_eq!(notebook.model().ai_document_id, Some(document_id));
assert_eq!(notebook.model().data, "- [ ] Ship the release");
assert!(!notebook.metadata.has_pending_content_changes());
assert!(!notebook.metadata.has_pending_online_only_change());
assert_eq!(
repository.read(&app, |repository, app| {
repository
.notebook(&notebook_id, app)
.unwrap()
.metadata
.folder_id
}),
expected_parent,
);
}
});
}
#[test]
fn local_folder_moves_reject_cycles_and_missing_or_trashed_destinations() {
App::test((), |mut app| async move {
let receiver = initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let parent_id = SyncId::ClientId(ClientId::new());
let child_id = SyncId::ClientId(ClientId::new());
let trashed_id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_folder_with_id(parent_id, "Plans".to_string(), None, ctx);
repository.create_folder_with_id(
child_id,
"Nested".to_string(),
Some(parent_id),
ctx,
);
repository.create_folder_with_id(trashed_id, "Deleted".to_string(), None, ctx);
repository.set_folder_trashed(trashed_id, true, ctx);
});
for _ in 0..4 {
receiver.recv().unwrap();
}
for location in [
CloudObjectLocation::Folder(parent_id),
CloudObjectLocation::Folder(child_id),
CloudObjectLocation::Folder(trashed_id),
CloudObjectLocation::Folder(SyncId::ClientId(ClientId::new())),
CloudObjectLocation::Space(Space::Shared),
] {
assert!(!repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Folder(parent_id),
location,
ctx,
)
}));
}
assert!(receiver.try_recv().is_err());
assert!(repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Folder(child_id),
CloudObjectLocation::Space(Space::Personal),
ctx,
)
}));
assert!(matches!(
receiver.recv().unwrap(),
ModelEvent::UpsertFolder { folder }
if folder.id == child_id && folder.metadata.folder_id.is_none()
));
});
}
#[test]
fn moving_local_objects_to_trash_is_persisted() {
App::test((), |mut app| async move {
let receiver = initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let id = SyncId::ClientId(ClientId::new());
repository.update(&mut app, |repository, ctx| {
repository.create_workflow_with_id(
id,
None,
Workflow::new("Build", "cargo build"),
ctx,
);
});
receiver.recv().unwrap();
assert!(repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Workflow(id),
CloudObjectLocation::Trash,
ctx,
)
}));
assert!(matches!(
receiver.recv().unwrap(),
ModelEvent::UpsertWorkflow { workflow }
if workflow.id == id && workflow.metadata.trashed_ts.is_some()
));
});
}
#[test]
fn local_moves_do_not_transfer_objects_across_ownership_boundaries() {
App::test((), |mut app| async move {
let receiver = initialize_app(&mut app);
let repository = LocalObjectRepository::handle(&app);
let local_id = SyncId::ClientId(ClientId::new());
let cloud_id = ClientId::new();
let cloud_folder_id = ClientId::new();
repository.update(&mut app, |repository, ctx| {
repository.create_notebook_with_id(local_id, None, CloudNotebookModel::default(), ctx);
});
receiver.recv().unwrap();
CloudModel::handle(&app).update(&mut app, |cloud_model, ctx| {
cloud_model.create_object(
SyncId::ClientId(cloud_id),
CloudNotebook::new_local(
CloudNotebookModel::default(),
Owner::mock_current_user(),
None,
cloud_id,
),
ctx,
);
cloud_model.create_object(
SyncId::ClientId(cloud_folder_id),
CloudFolder::new_local(
CloudFolderModel::new("Shared", false),
Owner::mock_current_user(),
None,
cloud_folder_id,
),
ctx,
);
});
assert!(!repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(local_id),
CloudObjectLocation::Folder(SyncId::ClientId(cloud_folder_id)),
ctx,
)
}));
assert!(!repository.update(&mut app, |repository, ctx| {
repository.move_object_to_location(
CloudObjectTypeAndId::Notebook(SyncId::ClientId(cloud_id)),
CloudObjectLocation::Space(Space::Personal),
ctx,
)
}));
assert!(receiver.try_recv().is_err());
});
}
#[test] #[test]
fn create_update_open_trash_and_delete_folder_are_local_and_persisted() { fn create_update_open_trash_and_delete_folder_are_local_and_persisted() {
App::test((), |mut app| async move { App::test((), |mut app| async move {
+2
View File
@@ -6,10 +6,12 @@
mod provider_run; mod provider_run;
mod runtime; mod runtime;
mod task_list;
mod tool_policy; mod tool_policy;
mod types; mod types;
pub use provider_run::*; pub use provider_run::*;
pub use runtime::*; pub use runtime::*;
pub use task_list::*;
pub use tool_policy::*; pub use tool_policy::*;
pub use types::*; pub use types::*;
+58 -2
View File
@@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize};
use crate::{ use crate::{
AgentError, ContentPart, ConversationMessage, MessageContent, MessageRole, PermissionDecision, AgentError, ContentPart, ConversationMessage, MessageContent, MessageRole, PermissionDecision,
PermissionRequest, StopReason, ToolCall, ToolCallDecision, ToolPolicy, ToolResult, PermissionRequest, StopReason, TaskListSnapshot, ToolCall, ToolCallDecision, ToolPolicy,
ToolResultStatus, Usage, ToolResult, ToolResultStatus, UPDATE_TASK_LIST_NAME, Usage, task_list_from_tool_result,
}; };
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
@@ -357,6 +357,9 @@ pub enum ProviderRunProtocolError {
InvalidTranscriptCompaction { InvalidTranscriptCompaction {
message: String, message: String,
}, },
InvalidTaskList {
message: String,
},
InvalidRestoredState { InvalidRestoredState {
message: String, message: String,
}, },
@@ -422,6 +425,7 @@ impl fmt::Display for ProviderRunProtocolError {
Self::InvalidTranscriptCompaction { message } => { Self::InvalidTranscriptCompaction { message } => {
write!(f, "invalid transcript compaction: {message}") write!(f, "invalid transcript compaction: {message}")
} }
Self::InvalidTaskList { message } => write!(f, "invalid task list: {message}"),
Self::InvalidRestoredState { message } => { Self::InvalidRestoredState { message } => {
write!(f, "invalid restored provider run: {message}") write!(f, "invalid restored provider run: {message}")
} }
@@ -439,6 +443,8 @@ pub struct ProviderRun {
epoch: RunEpoch, epoch: RunEpoch,
transcript: Vec<ConversationMessage>, transcript: Vec<ConversationMessage>,
tool_result_archive: Vec<ConversationMessage>, tool_result_archive: Vec<ConversationMessage>,
#[serde(default)]
task_list: Option<TaskListSnapshot>,
usage: Usage, usage: Usage,
model_turns: u32, model_turns: u32,
model_retries: u32, model_retries: u32,
@@ -460,6 +466,7 @@ impl ProviderRun {
epoch: RunEpoch::default(), epoch: RunEpoch::default(),
transcript, transcript,
tool_result_archive: Vec::new(), tool_result_archive: Vec::new(),
task_list: None,
usage: Usage::default(), usage: Usage::default(),
model_turns: 0, model_turns: 0,
model_retries: 0, model_retries: 0,
@@ -489,6 +496,23 @@ impl ProviderRun {
&self.transcript &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] { pub fn tool_result_archive(&self) -> &[ConversationMessage] {
&self.tool_result_archive &self.tool_result_archive
} }
@@ -564,6 +588,11 @@ impl ProviderRun {
if self.profile.as_str().is_empty() { if self.profile.as_str().is_empty() {
return Err(invalid("request profile must not be empty".to_string())); 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) { if self.limits.max_model_turns == Some(0) {
return Err(invalid("model-turn limit must be at least one".to_string())); 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 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| { let yielded_to_children = batch.calls.iter().any(|pending| {
is_local_agent_launch(&pending.call) is_local_agent_launch(&pending.call)
&& pending && pending
@@ -1281,6 +1324,7 @@ impl ProviderRun {
.is_some_and(|result| result.status == ToolResultStatus::Success) .is_some_and(|result| result.status == ToolResultStatus::Success)
}); });
self.transcript.push(tool_result_message(results)); self.transcript.push(tool_result_message(results));
self.task_list = task_list;
self.epoch = next_epoch; self.epoch = next_epoch;
// Launch acknowledgement is a yield, not an invitation to issue more tools. // 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. // 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::RecoveryPending
| PendingToolCallState::Resolved { .. } => {} | 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(()) Ok(())
} }
@@ -1,7 +1,7 @@
use serde_json::json; use serde_json::json;
use super::*; use super::*;
use crate::{AgentErrorKind, PermissionKind}; use crate::{AgentErrorKind, PermissionKind, TaskListItem, TaskListStatus};
fn initial_messages() -> Vec<ConversationMessage> { fn initial_messages() -> Vec<ConversationMessage> {
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"]); 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] #[test]
fn parallel_tool_results_commit_atomically_in_original_call_order() { fn parallel_tool_results_commit_atomically_in_original_call_order() {
let mut run = run(); 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 serde_json::Value as JsonValue;
use crate::{ use crate::{
ContentPart, ConversationMessage, MessageContent, ToolCall, ToolDefinition, ToolResult, ContentPart, ConversationMessage, MessageContent, TaskListSnapshot, ToolCall, ToolDefinition,
ToolResultStatus, ToolResult, ToolResultStatus, UPDATE_TASK_LIST_NAME,
}; };
pub const RECALL_TOOL_HISTORY_NAME: &str = "recall_tool_history"; 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 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] #[test]
fn recall_searches_archived_and_live_results_with_live_results_most_recent() { fn recall_searches_archived_and_live_results_with_live_results_most_recent() {
let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]); let policy = ToolPolicy::new(&[definition(RECALL_TOOL_HISTORY_NAME)]);