refactor: rename pod crate to worker
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
//! Built-in internal feature modules.
|
||||
//!
|
||||
//! These modules are compiled into the Worker host and contribute through the
|
||||
//! same descriptor-approved registry path used by feature modules. They are not
|
||||
//! an external plugin-loading surface.
|
||||
|
||||
pub mod task;
|
||||
pub mod ticket;
|
||||
|
||||
pub use task::{TaskFeature, task_tools_feature};
|
||||
pub use ticket::{
|
||||
TicketFeature, TicketFeatureAccess, ticket_tools_feature, ticket_tools_feature_with_access,
|
||||
ticket_tools_feature_with_options,
|
||||
};
|
||||
@@ -0,0 +1,573 @@
|
||||
//! Task tools built-in feature module.
|
||||
//!
|
||||
//! The built-in Task feature owns the session-lifetime [`TaskStore`] shared by
|
||||
//! the Task tools and reminder hooks. Worker hosts install this module through the
|
||||
//! feature contribution boundary and use its narrow snapshot surface for
|
||||
//! restore/rewind/compaction compatibility.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::Item;
|
||||
|
||||
mod store;
|
||||
mod tool_impl;
|
||||
|
||||
pub(crate) use self::tool_impl::task_tools;
|
||||
use store::snapshot_overview;
|
||||
pub(crate) use store::{TaskEntry, TaskStatus, TaskStore};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
use crate::hook::{
|
||||
Hook, HookPreRequestAction, HookPreToolAction, PreLlmRequest, PreRequestContext, PreToolCall,
|
||||
ToolCallSummary,
|
||||
};
|
||||
|
||||
const TASK_REMINDER_REQUEST_THRESHOLD: usize = 24;
|
||||
const TASK_REMINDER_COOLDOWN_REQUESTS: usize = 24;
|
||||
const TASK_MANAGEMENT_TOOL_NAMES: [&str; 2] = ["TaskCreate", "TaskUpdate"];
|
||||
|
||||
/// Construct the built-in Task feature module with a fresh session store.
|
||||
///
|
||||
/// The returned module contributes `TaskCreate`, `TaskUpdate`, `TaskGet`, and
|
||||
/// `TaskList` through descriptor-approved tool registration, plus built-in hooks
|
||||
/// that maintain Task-reminder state. Normal ToolRegistry and PreToolCall
|
||||
/// permission policy still applies at call time.
|
||||
pub fn task_tools_feature() -> TaskFeature {
|
||||
TaskFeature::new()
|
||||
}
|
||||
|
||||
/// Built-in Task feature state and contribution module.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TaskFeature {
|
||||
state: Arc<TaskFeatureState>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TaskFeatureState {
|
||||
task_store: TaskStore,
|
||||
reminder_state: TaskReminderState,
|
||||
}
|
||||
|
||||
impl TaskFeature {
|
||||
pub fn new() -> Self {
|
||||
Self::from_store(TaskStore::new())
|
||||
}
|
||||
|
||||
pub fn from_history(history: &[Item]) -> Self {
|
||||
Self::from_store(TaskStore::from_history(history))
|
||||
}
|
||||
|
||||
fn from_store(task_store: TaskStore) -> Self {
|
||||
Self {
|
||||
state: Arc::new(TaskFeatureState {
|
||||
task_store,
|
||||
reminder_state: TaskReminderState::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore the feature-owned store by replaying durable history into the
|
||||
/// existing shared store handle. Existing Task tool instances and hooks keep
|
||||
/// pointing at the same feature-owned store after rewind.
|
||||
pub fn restore_from_history(&self, history: &[Item]) {
|
||||
let restored = TaskStore::from_history(history);
|
||||
self.state.task_store.replace_with(restored.list());
|
||||
}
|
||||
|
||||
/// Feature-owned snapshot text used by compaction to preserve Task state.
|
||||
pub fn snapshot_text(&self) -> String {
|
||||
self.state.task_store.snapshot_text()
|
||||
}
|
||||
|
||||
/// Feature-owned compact summary used for the synthetic TaskList result.
|
||||
pub fn snapshot_overview(&self) -> String {
|
||||
snapshot_overview(&self.state.task_store.list())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn task_store(&self) -> TaskStore {
|
||||
self.state.task_store.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskFeature {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for TaskFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
FeatureDescriptor::builtin("task-tools", "Task tools")
|
||||
.with_description("Session-lifetime task tracking builtin tools")
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskCreate",
|
||||
"Create a session-lifetime user-visible task",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskUpdate",
|
||||
"Update a session-lifetime user-visible task",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskGet",
|
||||
"Get one session-lifetime user-visible task",
|
||||
))
|
||||
.with_tool(ToolDeclaration::new(
|
||||
"TaskList",
|
||||
"List session-lifetime user-visible tasks",
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
"task-reminder-pre-request",
|
||||
FeatureHookPoint::PreRequest,
|
||||
))
|
||||
.with_hook(HookDeclaration::new(
|
||||
"task-reminder-tool-usage",
|
||||
FeatureHookPoint::PreToolCall,
|
||||
))
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
let names = ["TaskCreate", "TaskList", "TaskGet", "TaskUpdate"];
|
||||
for (name, definition) in names
|
||||
.into_iter()
|
||||
.zip(task_tools(self.state.task_store.clone()))
|
||||
{
|
||||
context
|
||||
.tools()
|
||||
.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
|
||||
context.hooks().add_pre_request(
|
||||
"task-reminder-pre-request",
|
||||
TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&self.state),
|
||||
},
|
||||
)?;
|
||||
context.hooks().add_pre_tool_call(
|
||||
"task-reminder-tool-usage",
|
||||
TaskReminderToolUsageHook {
|
||||
state: Arc::clone(&self.state),
|
||||
},
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TaskReminderState {
|
||||
requests_since_last_task_management: AtomicUsize,
|
||||
requests_since_last_reminder: AtomicUsize,
|
||||
}
|
||||
|
||||
impl Default for TaskReminderState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
requests_since_last_task_management: AtomicUsize::new(0),
|
||||
requests_since_last_reminder: AtomicUsize::new(TASK_REMINDER_COOLDOWN_REQUESTS),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskReminderState {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn note_request(&self) -> (usize, usize) {
|
||||
let since_task_management = self
|
||||
.requests_since_last_task_management
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.saturating_add(1);
|
||||
let since_reminder = self
|
||||
.requests_since_last_reminder
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.saturating_add(1);
|
||||
(since_task_management, since_reminder)
|
||||
}
|
||||
|
||||
fn note_task_management(&self) {
|
||||
self.requests_since_last_task_management
|
||||
.store(0, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn note_reminder(&self) {
|
||||
self.requests_since_last_reminder
|
||||
.store(0, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskReminderPreRequestHook {
|
||||
state: Arc<TaskFeatureState>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreLlmRequest> for TaskReminderPreRequestHook {
|
||||
async fn call(&self, input: &PreRequestContext) -> HookPreRequestAction {
|
||||
let active_tasks: Vec<TaskEntry> = self
|
||||
.state
|
||||
.task_store
|
||||
.list()
|
||||
.into_iter()
|
||||
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Inprogress))
|
||||
.collect();
|
||||
if active_tasks.is_empty() {
|
||||
return HookPreRequestAction::Continue;
|
||||
}
|
||||
|
||||
let (since_task_management, since_reminder) = self.state.reminder_state.note_request();
|
||||
if since_task_management < TASK_REMINDER_REQUEST_THRESHOLD
|
||||
|| since_reminder < TASK_REMINDER_COOLDOWN_REQUESTS
|
||||
{
|
||||
return HookPreRequestAction::Continue;
|
||||
}
|
||||
|
||||
if let Some(system_items) = input.system_items() {
|
||||
self.state.reminder_state.note_reminder();
|
||||
system_items.append_task_reminder(render_task_reminder_body(&active_tasks));
|
||||
}
|
||||
HookPreRequestAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
struct TaskReminderToolUsageHook {
|
||||
state: Arc<TaskFeatureState>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hook<PreToolCall> for TaskReminderToolUsageHook {
|
||||
async fn call(&self, input: &ToolCallSummary) -> HookPreToolAction {
|
||||
if is_task_management_tool(&input.tool_name) {
|
||||
self.state.reminder_state.note_task_management();
|
||||
}
|
||||
HookPreToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn is_task_management_tool(name: &str) -> bool {
|
||||
TASK_MANAGEMENT_TOOL_NAMES.contains(&name)
|
||||
}
|
||||
|
||||
fn render_task_reminder_body(active_tasks: &[TaskEntry]) -> String {
|
||||
let mut body = String::from(
|
||||
"Active session tasks are still open. If progress changed, call TaskUpdate.\n",
|
||||
);
|
||||
for task in active_tasks {
|
||||
body.push_str(&format!(
|
||||
"- taskid {} ({}) {}\n",
|
||||
task.taskid, task.status, task.subject
|
||||
));
|
||||
}
|
||||
body.trim_end_matches('\n').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use session_store::{SystemItem, SystemReminderSource};
|
||||
|
||||
use super::*;
|
||||
use crate::hook::{PreRequestInfo, SystemItemAppendHandle};
|
||||
|
||||
fn pre_request_context(pending: Arc<Mutex<Vec<SystemItem>>>) -> PreRequestContext {
|
||||
PreRequestContext::new(
|
||||
PreRequestInfo {
|
||||
item_count: 1,
|
||||
estimated_tokens: None,
|
||||
turn_index: 0,
|
||||
tool_calls_this_turn: 0,
|
||||
},
|
||||
Some(SystemItemAppendHandle::new(pending)),
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_summary(name: &str) -> ToolCallSummary {
|
||||
ToolCallSummary {
|
||||
call_id: "call-id".into(),
|
||||
tool_name: name.into(),
|
||||
arguments: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_hook_appends_after_inactive_request_threshold() {
|
||||
let feature = TaskFeature::new();
|
||||
feature
|
||||
.task_store()
|
||||
.create("keep going".into(), "long task description".into());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
|
||||
let queued = pending.lock().expect("pending queue poisoned");
|
||||
assert_eq!(queued.len(), 1);
|
||||
let SystemItem::TaskReminder { body, .. } = &queued[0] else {
|
||||
panic!("unexpected system item: {:?}", queued[0]);
|
||||
};
|
||||
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
||||
assert_eq!(body.matches("</system-reminder>").count(), 1);
|
||||
assert!(body.contains("taskid 1"));
|
||||
assert!(body.contains("pending"));
|
||||
assert!(body.contains("keep going"));
|
||||
assert!(!body.contains("long task description"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_hook_retains_source() {
|
||||
let feature = TaskFeature::new();
|
||||
feature.task_store().create("typed".into(), String::new());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
}
|
||||
|
||||
let queued = pending.lock().expect("pending queue poisoned");
|
||||
let SystemItem::TaskReminder { source, body } = &queued[0] else {
|
||||
panic!("unexpected system item: {:?}", queued[0]);
|
||||
};
|
||||
assert_eq!(*source, SystemReminderSource::TaskInactivity);
|
||||
assert_eq!(body.matches("<system-reminder>").count(), 1);
|
||||
assert_eq!(body.matches("</system-reminder>").count(), 1);
|
||||
assert!(body.contains("typed"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_task_reminder_body_is_unwrapped_for_system_reminder_helper() {
|
||||
let feature = TaskFeature::new();
|
||||
let task = feature.task_store().create("body".into(), String::new());
|
||||
let body = render_task_reminder_body(&[task]);
|
||||
|
||||
assert!(!body.contains("<system-reminder>"));
|
||||
assert!(!body.contains("</system-reminder>"));
|
||||
assert!(body.contains("TaskUpdate"));
|
||||
assert!(body.contains("taskid 1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_reminder_state_starts_with_initial_cooldown_elapsed() {
|
||||
let state = TaskReminderState::new();
|
||||
|
||||
assert_eq!(
|
||||
state.requests_since_last_reminder.load(Ordering::Relaxed),
|
||||
TASK_REMINDER_COOLDOWN_REQUESTS
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.requests_since_last_task_management
|
||||
.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_management_tool_call_resets_reminder_inactivity_counter() {
|
||||
let feature = TaskFeature::new();
|
||||
feature
|
||||
.task_store()
|
||||
.create("track me".into(), String::new());
|
||||
let pre_request = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pre_tool = TaskReminderToolUsageHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = pre_tool.call(&tool_summary("TaskUpdate")).await;
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_respects_cooldown_after_reminder() {
|
||||
let feature = TaskFeature::new();
|
||||
feature
|
||||
.task_store()
|
||||
.create("cooldown".into(), String::new());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
}
|
||||
pending.lock().expect("pending queue poisoned").clear();
|
||||
for _ in 0..TASK_REMINDER_COOLDOWN_REQUESTS - 1 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_reminder_is_silent_when_no_active_tasks_exist() {
|
||||
let feature = TaskFeature::new();
|
||||
let done = feature
|
||||
.task_store()
|
||||
.create("done".into(), String::new())
|
||||
.taskid;
|
||||
feature
|
||||
.task_store()
|
||||
.update(done, Some(TaskStatus::Completed), None, None)
|
||||
.expect("complete task");
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn inactive_requests_without_active_tasks_do_not_prime_task_reminder() {
|
||||
let feature = TaskFeature::new();
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
|
||||
feature
|
||||
.task_store()
|
||||
.create("new active".into(), String::new());
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = hook.call(&pre_request_context(Arc::clone(&pending))).await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_create_reset_does_not_block_first_reminder_cooldown() {
|
||||
let feature = TaskFeature::new();
|
||||
let pre_request = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pre_tool = TaskReminderToolUsageHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let pending = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD * 2 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
|
||||
let _ = pre_tool.call(&tool_summary("TaskCreate")).await;
|
||||
feature
|
||||
.task_store()
|
||||
.create("created after idle".into(), String::new());
|
||||
assert_eq!(
|
||||
feature
|
||||
.state
|
||||
.reminder_state
|
||||
.requests_since_last_reminder
|
||||
.load(Ordering::Relaxed),
|
||||
TASK_REMINDER_COOLDOWN_REQUESTS,
|
||||
"TaskCreate reset must not clear the initial reminder cooldown"
|
||||
);
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD - 1 {
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert!(pending.lock().expect("pending queue poisoned").is_empty());
|
||||
}
|
||||
let _ = pre_request
|
||||
.call(&pre_request_context(Arc::clone(&pending)))
|
||||
.await;
|
||||
assert_eq!(pending.lock().expect("pending queue poisoned").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_system_item_handle_does_not_mark_reminder_sent() {
|
||||
let feature = TaskFeature::new();
|
||||
feature.task_store().create("handle".into(), String::new());
|
||||
let hook = TaskReminderPreRequestHook {
|
||||
state: Arc::clone(&feature.state),
|
||||
};
|
||||
let no_handle = PreRequestContext::new(
|
||||
PreRequestInfo {
|
||||
item_count: 1,
|
||||
estimated_tokens: None,
|
||||
turn_index: 0,
|
||||
tool_calls_this_turn: 0,
|
||||
},
|
||||
None,
|
||||
);
|
||||
|
||||
for _ in 0..TASK_REMINDER_REQUEST_THRESHOLD {
|
||||
let _ = hook.call(&no_handle).await;
|
||||
}
|
||||
assert_eq!(
|
||||
feature
|
||||
.state
|
||||
.reminder_state
|
||||
.requests_since_last_reminder
|
||||
.load(Ordering::Relaxed),
|
||||
TASK_REMINDER_COOLDOWN_REQUESTS + TASK_REMINDER_REQUEST_THRESHOLD,
|
||||
"without a handle the hook must not record a reminder as emitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_from_history_keeps_existing_store_handle_for_installed_tools() {
|
||||
let feature = TaskFeature::new();
|
||||
let handle = feature.task_store();
|
||||
handle.create("old".into(), String::new());
|
||||
let history = vec![Item::tool_call(
|
||||
"c1",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"restored","description":"from history"}"#,
|
||||
)];
|
||||
|
||||
feature.restore_from_history(&history);
|
||||
|
||||
let tasks = handle.list();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].subject, "restored");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
//! Task domain state and snapshot/replay support.
|
||||
//!
|
||||
//! The store survives compaction and Worker restart by replaying TaskCreate /
|
||||
//! TaskUpdate tool-call arguments and compacted TaskStore snapshots from
|
||||
//! persisted history.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use llm_engine::Item;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum TaskStatus {
|
||||
Pending,
|
||||
Inprogress,
|
||||
Completed,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TaskStatus {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let s = match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Inprogress => "inprogress",
|
||||
Self::Completed => "completed",
|
||||
Self::Deleted => "deleted",
|
||||
};
|
||||
f.write_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TaskEntry {
|
||||
pub taskid: u64,
|
||||
pub status: TaskStatus,
|
||||
pub subject: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Inner {
|
||||
next_taskid: u64,
|
||||
tasks: Vec<TaskEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TaskStore {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TaskSnapshot {
|
||||
pub tasks: Vec<TaskEntry>,
|
||||
}
|
||||
|
||||
impl TaskStore {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(Inner {
|
||||
next_taskid: 1,
|
||||
tasks: Vec::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create(&self, subject: String, description: String) -> TaskEntry {
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let task = TaskEntry {
|
||||
taskid: inner.next_taskid,
|
||||
status: TaskStatus::Pending,
|
||||
subject,
|
||||
description,
|
||||
};
|
||||
inner.next_taskid = inner.next_taskid.saturating_add(1);
|
||||
inner.tasks.push(task.clone());
|
||||
task
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<TaskEntry> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.tasks
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn get(&self, taskid: u64) -> Option<TaskEntry> {
|
||||
self.inner
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.tasks
|
||||
.iter()
|
||||
.find(|t| t.taskid == taskid)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn update(
|
||||
&self,
|
||||
taskid: u64,
|
||||
status: Option<TaskStatus>,
|
||||
subject: Option<String>,
|
||||
description: Option<String>,
|
||||
) -> Result<TaskEntry, TaskStoreError> {
|
||||
if status.is_none() && subject.is_none() && description.is_none() {
|
||||
return Err(TaskStoreError::NoFields);
|
||||
}
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let task = inner
|
||||
.tasks
|
||||
.iter_mut()
|
||||
.find(|t| t.taskid == taskid)
|
||||
.ok_or(TaskStoreError::Missing(taskid))?;
|
||||
if let Some(status) = status {
|
||||
task.status = status;
|
||||
}
|
||||
if let Some(subject) = subject {
|
||||
task.subject = subject;
|
||||
}
|
||||
if let Some(description) = description {
|
||||
task.description = description;
|
||||
}
|
||||
Ok(task.clone())
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> TaskSnapshot {
|
||||
TaskSnapshot { tasks: self.list() }
|
||||
}
|
||||
|
||||
pub fn replay_history(&self, history: &[Item]) {
|
||||
for item in history {
|
||||
match item {
|
||||
Item::Message { content, .. } => {
|
||||
for part in content {
|
||||
let text = part.as_text();
|
||||
if let Some(snapshot) = parse_compact_snapshot_text(text) {
|
||||
self.replace_with(snapshot);
|
||||
}
|
||||
}
|
||||
}
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => match name.as_str() {
|
||||
"TaskCreate" => {
|
||||
if let Ok(params) =
|
||||
serde_json::from_str::<ReplayTaskCreateParams>(arguments)
|
||||
{
|
||||
let _ = self.create(params.subject, params.description);
|
||||
}
|
||||
}
|
||||
"TaskUpdate" => {
|
||||
if let Ok(params) =
|
||||
serde_json::from_str::<ReplayTaskUpdateParams>(arguments)
|
||||
{
|
||||
let _ = self.update(
|
||||
params.taskid,
|
||||
params.status,
|
||||
params.subject,
|
||||
params.description,
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn replace_with(&self, tasks: Vec<TaskEntry>) {
|
||||
let next_taskid = tasks
|
||||
.iter()
|
||||
.map(|t| t.taskid)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1)
|
||||
.max(1);
|
||||
let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner());
|
||||
inner.tasks = tasks;
|
||||
inner.next_taskid = next_taskid;
|
||||
}
|
||||
|
||||
pub fn from_history(history: &[Item]) -> Self {
|
||||
let store = Self::new();
|
||||
store.replay_history(history);
|
||||
store
|
||||
}
|
||||
|
||||
pub fn snapshot_text(&self) -> String {
|
||||
let snapshot = self.snapshot();
|
||||
render_snapshot(&snapshot.tasks)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TaskStoreError {
|
||||
Missing(u64),
|
||||
NoFields,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TaskStoreError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Missing(id) => write!(f, "taskid {id} not found"),
|
||||
Self::NoFields => {
|
||||
f.write_str("at least one of status, subject, description is required")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TaskStoreError {}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayTaskCreateParams {
|
||||
subject: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayTaskUpdateParams {
|
||||
taskid: u64,
|
||||
#[serde(default)]
|
||||
status: Option<TaskStatus>,
|
||||
#[serde(default)]
|
||||
subject: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
pub fn snapshot_overview(tasks: &[TaskEntry]) -> String {
|
||||
let pending = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Pending)
|
||||
.count();
|
||||
let inprogress = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Inprogress)
|
||||
.count();
|
||||
let completed = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Completed)
|
||||
.count();
|
||||
let deleted = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Deleted)
|
||||
.count();
|
||||
format!(
|
||||
"TaskStore: {} task(s) (pending: {pending}, inprogress: {inprogress}, completed: {completed}, deleted: {deleted})",
|
||||
tasks.len()
|
||||
)
|
||||
}
|
||||
|
||||
pub fn render_snapshot(tasks: &[TaskEntry]) -> String {
|
||||
let snapshot = TaskSnapshot {
|
||||
tasks: tasks.to_vec(),
|
||||
};
|
||||
let json =
|
||||
serde_json::to_string_pretty(&snapshot).unwrap_or_else(|_| String::from("{\"tasks\":[]}"));
|
||||
format!("{}\n\n```json\n{}\n```\n", snapshot_overview(tasks), json)
|
||||
}
|
||||
|
||||
pub(super) fn parse_compact_snapshot_text(text: &str) -> Option<Vec<TaskEntry>> {
|
||||
if !text.starts_with("[Session TaskStore snapshot]") {
|
||||
return None;
|
||||
}
|
||||
let start_marker = "```json\n";
|
||||
let end_marker = "\n```";
|
||||
let start = text.find(start_marker)? + start_marker.len();
|
||||
let rest = &text[start..];
|
||||
let end = rest.find(end_marker)?;
|
||||
let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?;
|
||||
Some(snapshot.tasks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replay_history_reconstructs_store_and_ignores_malformed_calls() {
|
||||
let history = vec![
|
||||
Item::tool_call("c1", "TaskCreate", r#"{"subject":"a","description":"A"}"#),
|
||||
Item::tool_call("bad", "TaskCreate", r#"{"subject":1}"#),
|
||||
Item::tool_call("c2", "TaskCreate", r#"{"subject":"b","description":"B"}"#),
|
||||
Item::tool_call("u1", "TaskUpdate", r#"{"taskid":2,"status":"completed"}"#),
|
||||
Item::tool_call("bad2", "TaskUpdate", r#"{"taskid":99,"status":"deleted"}"#),
|
||||
];
|
||||
let store = TaskStore::from_history(&history);
|
||||
let tasks = store.list();
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(tasks[0].taskid, 1);
|
||||
assert_eq!(tasks[0].status, TaskStatus::Pending);
|
||||
assert_eq!(tasks[1].taskid, 2);
|
||||
assert_eq!(tasks[1].status, TaskStatus::Completed);
|
||||
}
|
||||
|
||||
/// Wrap snapshot text the way `Worker::try_pre_run_compact` does, so tests
|
||||
/// exercise the exact format that goes through the session log.
|
||||
fn wrap_snapshot_system_message(snapshot: &str) -> String {
|
||||
format!(
|
||||
"[Session TaskStore snapshot]\n\n{snapshot}\n\n\
|
||||
This is the complete session task list preserved across compaction. \
|
||||
The following TaskList tool result presents the same state through the tool lane."
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replay_history_uses_compact_snapshot_and_continues_updates() {
|
||||
let pre = TaskStore::new();
|
||||
pre.create("kept".into(), "from compact".into());
|
||||
pre.update(1, Some(TaskStatus::Inprogress), None, None)
|
||||
.unwrap();
|
||||
let history = vec![
|
||||
Item::system_message(wrap_snapshot_system_message(&pre.snapshot_text())),
|
||||
Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#),
|
||||
Item::tool_call(
|
||||
"c2",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"new","description":"after compact"}"#,
|
||||
),
|
||||
];
|
||||
let store = TaskStore::from_history(&history);
|
||||
let tasks = store.list();
|
||||
assert_eq!(tasks.len(), 2);
|
||||
assert_eq!(tasks[0].taskid, 1);
|
||||
assert_eq!(tasks[0].status, TaskStatus::Completed);
|
||||
assert_eq!(tasks[1].taskid, 2);
|
||||
assert_eq!(tasks[1].subject, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_snapshot_supersedes_pre_compact_taskcreates_in_retained() {
|
||||
// Mirrors the post-compact layout: pre-compact `TaskCreate` calls are
|
||||
// preserved verbatim in retained_items, and the snapshot trails them.
|
||||
// The trailing snapshot must reset the store to the captured state so
|
||||
// pre-compact `TaskCreate`s do not surface as duplicates.
|
||||
let pre = TaskStore::new();
|
||||
pre.create("A".into(), "A-desc".into());
|
||||
pre.update(1, Some(TaskStatus::Completed), None, None)
|
||||
.unwrap();
|
||||
pre.create("B".into(), "B-desc".into());
|
||||
pre.update(2, Some(TaskStatus::Inprogress), None, None)
|
||||
.unwrap();
|
||||
let history = vec![
|
||||
Item::tool_call(
|
||||
"c1",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"A","description":"A-desc"}"#,
|
||||
),
|
||||
Item::tool_call("u1", "TaskUpdate", r#"{"taskid":1,"status":"completed"}"#),
|
||||
Item::tool_call(
|
||||
"c2",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"B","description":"B-desc"}"#,
|
||||
),
|
||||
Item::tool_call("u2", "TaskUpdate", r#"{"taskid":2,"status":"inprogress"}"#),
|
||||
Item::system_message(wrap_snapshot_system_message(&pre.snapshot_text())),
|
||||
Item::tool_call("compact-tasklist", "TaskList", "{}"),
|
||||
Item::tool_call(
|
||||
"c3",
|
||||
"TaskCreate",
|
||||
r#"{"subject":"C","description":"after compact"}"#,
|
||||
),
|
||||
];
|
||||
let store = TaskStore::from_history(&history);
|
||||
let tasks = store.list();
|
||||
assert_eq!(tasks.len(), 3);
|
||||
assert_eq!(tasks[0].taskid, 1);
|
||||
assert_eq!(tasks[0].subject, "A");
|
||||
assert_eq!(tasks[0].status, TaskStatus::Completed);
|
||||
assert_eq!(tasks[1].taskid, 2);
|
||||
assert_eq!(tasks[1].subject, "B");
|
||||
assert_eq!(tasks[1].status, TaskStatus::Inprogress);
|
||||
assert_eq!(tasks[2].taskid, 3);
|
||||
assert_eq!(tasks[2].subject, "C");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_round_trips_multiline_subject_and_description() {
|
||||
// Subject / description with embedded newlines and shape-breaking
|
||||
// characters must survive snapshot serialization unchanged.
|
||||
let pre = TaskStore::new();
|
||||
pre.create(
|
||||
"subject with\nembedded newline\n- bullet".into(),
|
||||
"desc:\n status: not-actually-a-field\n ```code fence```".into(),
|
||||
);
|
||||
pre.update(1, Some(TaskStatus::Inprogress), None, None)
|
||||
.unwrap();
|
||||
|
||||
let history = vec![Item::system_message(wrap_snapshot_system_message(
|
||||
&pre.snapshot_text(),
|
||||
))];
|
||||
let store = TaskStore::from_history(&history);
|
||||
let tasks = store.list();
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].subject, "subject with\nembedded newline\n- bullet");
|
||||
assert_eq!(
|
||||
tasks[0].description,
|
||||
"desc:\n status: not-actually-a-field\n ```code fence```"
|
||||
);
|
||||
assert_eq!(tasks[0].status, TaskStatus::Inprogress);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_compact_tasklist_pair_is_well_formed() {
|
||||
// Mirrors `Worker::try_pre_run_compact`'s synthetic insertion:
|
||||
// a system snapshot message followed by a TaskList tool_call/tool_result
|
||||
// pair sharing the `compact-tasklist` id. Verify the structural
|
||||
// contract every provider request builder relies on (matched call_id,
|
||||
// tool name, content recoverable to the same TaskStore state).
|
||||
let pre = TaskStore::new();
|
||||
pre.create("plan".into(), "do A then B".into());
|
||||
|
||||
let snapshot_text = pre.snapshot_text();
|
||||
let system = Item::system_message(wrap_snapshot_system_message(&snapshot_text));
|
||||
let call = Item::tool_call("compact-tasklist", "TaskList", "{}");
|
||||
let result = Item::tool_result_with_content(
|
||||
"compact-tasklist",
|
||||
snapshot_overview(&pre.list()),
|
||||
snapshot_text.clone(),
|
||||
);
|
||||
|
||||
// The system message embeds a parseable snapshot.
|
||||
let extracted = system
|
||||
.as_text()
|
||||
.and_then(parse_compact_snapshot_text)
|
||||
.expect("system message should parse as snapshot");
|
||||
assert_eq!(extracted, pre.list());
|
||||
|
||||
// The synthetic call/result pair shares one call_id and carries the
|
||||
// expected tool name + detailed content.
|
||||
match (&call, &result) {
|
||||
(
|
||||
Item::ToolCall {
|
||||
call_id: c_id,
|
||||
name,
|
||||
..
|
||||
},
|
||||
Item::ToolResult {
|
||||
call_id: r_id,
|
||||
content,
|
||||
..
|
||||
},
|
||||
) => {
|
||||
assert_eq!(c_id.as_str(), r_id.as_str());
|
||||
assert_eq!(c_id.as_str(), "compact-tasklist");
|
||||
assert_eq!(name, "TaskList");
|
||||
assert_eq!(content.as_deref(), Some(snapshot_text.as_str()));
|
||||
}
|
||||
other => panic!("unexpected synthetic pair shape: {other:?}"),
|
||||
}
|
||||
|
||||
// Replaying the full triple reconstructs the same TaskStore.
|
||||
let store = TaskStore::from_history(&[system, call, result]);
|
||||
assert_eq!(store.list(), pre.list());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Task built-in tool implementations.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::store::{TaskEntry, TaskStatus, TaskStore, render_snapshot, snapshot_overview};
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskCreateParams {
|
||||
/// One-line task subject.
|
||||
subject: String,
|
||||
/// Detailed task description.
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskListParams {}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskGetParams {
|
||||
taskid: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct TaskUpdateParams {
|
||||
taskid: u64,
|
||||
#[serde(default)]
|
||||
status: Option<TaskStatus>,
|
||||
#[serde(default)]
|
||||
subject: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
struct TaskCreateTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskListTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskGetTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
struct TaskUpdateTool {
|
||||
store: TaskStore,
|
||||
}
|
||||
|
||||
const CREATE_DESCRIPTION: &str = "Create a session-lifetime task only when user-visible \
|
||||
progress tracking is genuinely useful: multiple active tasks must be remembered, or the work \
|
||||
will involve long edits, long-running commands, extended investigation, or interruption-prone \
|
||||
coordination. Do not create a task just because a request has several steps, and do not create \
|
||||
one for short questions, quick checks, single reviews, or one-off commands. Prefer updating an \
|
||||
existing active task over creating a duplicate. Input only `subject` and `description`; `taskid` \
|
||||
is assigned automatically and initial `status` is `pending`.";
|
||||
const LIST_DESCRIPTION: &str = "List every session-lifetime task, including completed and \
|
||||
deleted entries. Tasks are user-visible real-time status for short-term current-work tracking. \
|
||||
Takes an empty object as input.";
|
||||
const GET_DESCRIPTION: &str = "Get one session-lifetime task by `taskid`. Tasks are \
|
||||
user-visible real-time status for short-term current-work tracking. Returns an error if the task \
|
||||
does not exist.";
|
||||
const UPDATE_DESCRIPTION: &str = "Update an existing session-lifetime task when meaningful \
|
||||
progress changes between substantial steps. Tasks are user-visible real-time status, so avoid \
|
||||
churn for trivial substeps. Keep status current with `pending`, `inprogress`, `completed`, or \
|
||||
`deleted`. Provide `taskid` and at least one of `status`, `subject`, or `description`; deletion is \
|
||||
logical (`status = deleted`). If an unexpected problem blocks progress, do not force the next \
|
||||
step: leave the task as-is, summarize the problem to the user, and end the turn.";
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskCreateTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskCreateParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskCreate input: {e}")))?;
|
||||
let created = self.store.create(params.subject, params.description);
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Created task {} ({})\n{}",
|
||||
created.taskid,
|
||||
created.status,
|
||||
snapshot_overview(&tasks)
|
||||
),
|
||||
&created,
|
||||
&tasks,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskListTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let _: TaskListParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskList input: {e}")))?;
|
||||
let tasks = self.store.list();
|
||||
Ok(ToolOutput {
|
||||
summary: snapshot_overview(&tasks),
|
||||
content: Some(render_snapshot(&tasks)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskGetTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskGetParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskGet input: {e}")))?;
|
||||
let task = self.store.get(params.taskid).ok_or_else(|| {
|
||||
ToolError::ExecutionFailed(format!("taskid {} not found", params.taskid))
|
||||
})?;
|
||||
let content = serde_json::to_string_pretty(&task).unwrap_or_else(|_| format!("{task:?}"));
|
||||
Ok(ToolOutput {
|
||||
summary: format!("Task {} ({}) {}", task.taskid, task.status, task.subject),
|
||||
content: Some(content),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for TaskUpdateTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
input_json: &str,
|
||||
_ctx: llm_engine::tool::ToolExecutionContext,
|
||||
) -> Result<ToolOutput, ToolError> {
|
||||
let params: TaskUpdateParams = serde_json::from_str(input_json)
|
||||
.map_err(|e| ToolError::InvalidArgument(format!("invalid TaskUpdate input: {e}")))?;
|
||||
let updated = self
|
||||
.store
|
||||
.update(
|
||||
params.taskid,
|
||||
params.status,
|
||||
params.subject,
|
||||
params.description,
|
||||
)
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
let tasks = self.store.list();
|
||||
Ok(task_output(
|
||||
format!(
|
||||
"Updated task {} ({})\n{}",
|
||||
updated.taskid,
|
||||
updated.status,
|
||||
snapshot_overview(&tasks)
|
||||
),
|
||||
&updated,
|
||||
&tasks,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn task_output(summary: String, task: &TaskEntry, tasks: &[TaskEntry]) -> ToolOutput {
|
||||
let content = serde_json::json!({
|
||||
"task": task,
|
||||
"snapshot": { "tasks": tasks },
|
||||
});
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(serde_json::to_string_pretty(&content).unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
fn task_create_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskCreateParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskCreate")
|
||||
.description(CREATE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskCreateTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_list_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskListParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskList")
|
||||
.description(LIST_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskListTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_get_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskGetParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskGet")
|
||||
.description(GET_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskGetTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
fn task_update_tool(store: TaskStore) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(TaskUpdateParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("TaskUpdate")
|
||||
.description(UPDATE_DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(TaskUpdateTool {
|
||||
store: store.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn task_tools(store: TaskStore) -> Vec<ToolDefinition> {
|
||||
vec![
|
||||
task_create_tool(store.clone()),
|
||||
task_list_tool(store.clone()),
|
||||
task_get_tool(store.clone()),
|
||||
task_update_tool(store),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tool(def: ToolDefinition) -> Arc<dyn Tool> {
|
||||
let (_, tool) = def();
|
||||
tool
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_tools_create_list_get_update() {
|
||||
let store = TaskStore::new();
|
||||
let create = tool(task_create_tool(store.clone()));
|
||||
let list = tool(task_list_tool(store.clone()));
|
||||
let get = tool(task_get_tool(store.clone()));
|
||||
let update = tool(task_update_tool(store.clone()));
|
||||
|
||||
let out = create
|
||||
.execute(
|
||||
r#"{"subject":"implement","description":"write code"}"#,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Created task 1"));
|
||||
assert_eq!(store.get(1).unwrap().status, TaskStatus::Pending);
|
||||
|
||||
let out = update
|
||||
.execute(
|
||||
r#"{"taskid":1,"status":"inprogress","subject":"implement tasks"}"#,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Updated task 1"));
|
||||
let task = store.get(1).unwrap();
|
||||
assert_eq!(task.status, TaskStatus::Inprogress);
|
||||
assert_eq!(task.subject, "implement tasks");
|
||||
|
||||
let out = get
|
||||
.execute(r#"{"taskid":1}"#, Default::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(out.summary.contains("Task 1 (inprogress)"));
|
||||
assert!(out.content.unwrap().contains("implement tasks"));
|
||||
|
||||
let out = list.execute("{}", Default::default()).await.unwrap();
|
||||
assert!(out.summary.contains("1 task(s)"));
|
||||
let content = out.content.unwrap();
|
||||
assert!(content.contains("\"taskid\": 1"));
|
||||
assert!(content.contains("```json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn task_update_validates_existing_and_at_least_one_field() {
|
||||
let store = TaskStore::new();
|
||||
store.create("s".into(), "d".into());
|
||||
let update = tool(task_update_tool(store));
|
||||
|
||||
let err = update
|
||||
.execute(r#"{"taskid":1}"#, Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("at least one"));
|
||||
|
||||
let err = update
|
||||
.execute(r#"{"taskid":99,"status":"deleted"}"#, Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("taskid 99 not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//! Built-in Ticket feature adapter.
|
||||
//!
|
||||
//! The ticket crate owns Ticket domain logic and Tool implementations. This
|
||||
//! module only resolves the local backend root, declares the built-in feature,
|
||||
//! and contributes those tools through the normal feature registry path.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ticket::{
|
||||
LocalTicketBackend,
|
||||
config::{DEFAULT_TICKET_BACKEND_RELATIVE_PATH, TicketConfig},
|
||||
tool::{
|
||||
TICKET_BASE_READ_ONLY_TOOL_NAMES, TICKET_BASE_TOOL_NAMES,
|
||||
TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES,
|
||||
TICKET_READ_ONLY_TOOL_NAMES, TICKET_TOOL_NAMES, ticket_tool_description, ticket_tools,
|
||||
},
|
||||
};
|
||||
|
||||
use crate::feature::{
|
||||
FeatureDescriptor, FeatureDiagnostic, FeatureInstallContext, FeatureInstallError,
|
||||
FeatureModule, ToolContribution, ToolDeclaration,
|
||||
};
|
||||
|
||||
const FEATURE_ID: &str = "ticket";
|
||||
const FEATURE_NAME: &str = "Ticket tools";
|
||||
const FEATURE_DESCRIPTION: &str = "Typed local Ticket work-item operations over a bounded backend root. \
|
||||
The tools operate through the ticket crate backend and do not grant generic filesystem write scope.";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum TicketFeatureAccess {
|
||||
/// Status/diagnostic access for views such as Companion that must not mutate Tickets.
|
||||
ReadOnly,
|
||||
/// Full Ticket lifecycle access, including the read-only tools and all mutating Ticket tools.
|
||||
Lifecycle,
|
||||
}
|
||||
|
||||
impl TicketFeatureAccess {
|
||||
pub fn base_tool_names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::ReadOnly => &TICKET_BASE_READ_ONLY_TOOL_NAMES,
|
||||
Self::Lifecycle => &TICKET_BASE_TOOL_NAMES,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn orchestration_tool_names(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::ReadOnly => &TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES,
|
||||
Self::Lifecycle => &TICKET_ORCHESTRATION_TOOL_NAMES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TicketFeature {
|
||||
backend_root: PathBuf,
|
||||
record_language: Option<String>,
|
||||
config_error: Option<String>,
|
||||
access: TicketFeatureAccess,
|
||||
include_base_tools: bool,
|
||||
include_orchestration_tools: bool,
|
||||
}
|
||||
|
||||
impl TicketFeature {
|
||||
pub fn new(backend_root: impl Into<PathBuf>) -> Self {
|
||||
Self::new_with_access(backend_root, TicketFeatureAccess::Lifecycle)
|
||||
}
|
||||
|
||||
pub fn new_with_access(backend_root: impl Into<PathBuf>, access: TicketFeatureAccess) -> Self {
|
||||
Self::new_with_options(backend_root, Some(access), true)
|
||||
}
|
||||
|
||||
pub fn new_with_options(
|
||||
backend_root: impl Into<PathBuf>,
|
||||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
backend_root: backend_root.into(),
|
||||
record_language: None,
|
||||
config_error: None,
|
||||
access: access.unwrap_or(TicketFeatureAccess::Lifecycle),
|
||||
include_base_tools: access.is_some(),
|
||||
include_orchestration_tools,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_workspace(workspace: impl AsRef<Path>) -> Self {
|
||||
Self::for_workspace_with_access(workspace, TicketFeatureAccess::Lifecycle)
|
||||
}
|
||||
|
||||
pub fn for_workspace_with_access(
|
||||
workspace: impl AsRef<Path>,
|
||||
access: TicketFeatureAccess,
|
||||
) -> Self {
|
||||
Self::for_workspace_with_options(workspace, Some(access), true)
|
||||
}
|
||||
|
||||
pub fn for_workspace_with_options(
|
||||
workspace: impl AsRef<Path>,
|
||||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
) -> Self {
|
||||
let workspace = workspace.as_ref();
|
||||
match TicketConfig::load_workspace(workspace) {
|
||||
Ok(config) => {
|
||||
let backend_root = config.backend_root().to_path_buf();
|
||||
let record_language = config.ticket_record_language().map(str::to_string);
|
||||
let mut feature =
|
||||
Self::new_with_options(backend_root, access, include_orchestration_tools);
|
||||
feature.record_language = record_language;
|
||||
feature
|
||||
}
|
||||
Err(error) => {
|
||||
let access_value = access.unwrap_or(TicketFeatureAccess::Lifecycle);
|
||||
Self {
|
||||
backend_root: workspace.join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH),
|
||||
record_language: None,
|
||||
config_error: Some(error.to_string()),
|
||||
access: access_value,
|
||||
include_base_tools: access.is_some(),
|
||||
include_orchestration_tools,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backend_root(&self) -> &Path {
|
||||
&self.backend_root
|
||||
}
|
||||
|
||||
pub fn access(&self) -> TicketFeatureAccess {
|
||||
self.access
|
||||
}
|
||||
|
||||
fn enabled_tool_names(&self) -> Vec<&'static str> {
|
||||
if self.include_base_tools && self.include_orchestration_tools {
|
||||
return match self.access {
|
||||
TicketFeatureAccess::ReadOnly => TICKET_READ_ONLY_TOOL_NAMES.to_vec(),
|
||||
TicketFeatureAccess::Lifecycle => TICKET_TOOL_NAMES.to_vec(),
|
||||
};
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
if self.include_base_tools {
|
||||
names.extend_from_slice(self.access.base_tool_names());
|
||||
}
|
||||
if self.include_orchestration_tools {
|
||||
names.extend_from_slice(self.access.orchestration_tool_names());
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
fn usable_backend_root(&self) -> Result<PathBuf, String> {
|
||||
let root = self
|
||||
.backend_root
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("ticket backend root is not usable: {error}"))?;
|
||||
if !root.is_dir() {
|
||||
return Err("ticket backend root is not a directory".to_string());
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
}
|
||||
|
||||
impl FeatureModule for TicketFeature {
|
||||
fn descriptor(&self) -> FeatureDescriptor {
|
||||
let mut descriptor = FeatureDescriptor::builtin(FEATURE_ID, FEATURE_NAME)
|
||||
.with_description(FEATURE_DESCRIPTION);
|
||||
let enabled_tool_names = self.enabled_tool_names();
|
||||
for name in &enabled_tool_names {
|
||||
descriptor = descriptor.with_tool(ToolDeclaration::new(
|
||||
*name,
|
||||
ticket_tool_description(name, self.record_language.as_deref()),
|
||||
));
|
||||
}
|
||||
descriptor
|
||||
}
|
||||
|
||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||
if let Some(error) = &self.config_error {
|
||||
context
|
||||
.diagnostics()
|
||||
.push(FeatureDiagnostic::warning(format!(
|
||||
"Ticket tools not registered: {error}"
|
||||
)));
|
||||
return Ok(());
|
||||
}
|
||||
let usable_root = match self.usable_backend_root() {
|
||||
Ok(root) => root,
|
||||
Err(reason) => {
|
||||
context
|
||||
.diagnostics()
|
||||
.push(FeatureDiagnostic::warning(format!(
|
||||
"Ticket tools not registered: {reason}; root={} ",
|
||||
self.backend_root.display()
|
||||
)));
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let backend = LocalTicketBackend::new(usable_root)
|
||||
.with_record_language(self.record_language.as_deref());
|
||||
let allowed_tool_names = self.enabled_tool_names();
|
||||
let mut tools = context.tools();
|
||||
for definition in ticket_tools(backend) {
|
||||
let (meta, _) = definition();
|
||||
let name = meta.name.clone();
|
||||
if !allowed_tool_names
|
||||
.iter()
|
||||
.any(|allowed| *allowed == name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
tools.register(ToolContribution::new(name, definition))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ticket_tools_feature(workspace: impl AsRef<Path>) -> TicketFeature {
|
||||
TicketFeature::for_workspace(workspace)
|
||||
}
|
||||
|
||||
pub fn ticket_tools_feature_with_access(
|
||||
workspace: impl AsRef<Path>,
|
||||
access: TicketFeatureAccess,
|
||||
) -> TicketFeature {
|
||||
TicketFeature::for_workspace_with_access(workspace, access)
|
||||
}
|
||||
|
||||
pub fn ticket_tools_feature_with_options(
|
||||
workspace: impl AsRef<Path>,
|
||||
access: Option<TicketFeatureAccess>,
|
||||
include_orchestration_tools: bool,
|
||||
) -> TicketFeature {
|
||||
TicketFeature::for_workspace_with_options(workspace, access, include_orchestration_tools)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::feature::{FeatureRegistryBuilder, FeatureRuntimeKind};
|
||||
use crate::hook::HookRegistryBuilder;
|
||||
use tempfile::TempDir;
|
||||
use ticket::tool::{
|
||||
TICKET_BASE_TOOL_NAMES, TICKET_ORCHESTRATION_TOOL_NAMES, TICKET_READ_ONLY_TOOL_NAMES,
|
||||
TICKET_TOOL_NAMES,
|
||||
};
|
||||
|
||||
fn make_ticket_root(root: &Path) {
|
||||
std::fs::create_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn write_ticket_config(workspace: &Path, content: &str) {
|
||||
let yoi_dir = workspace.join(".yoi");
|
||||
std::fs::create_dir_all(&yoi_dir).unwrap();
|
||||
std::fs::write(yoi_dir.join("ticket.config.toml"), content).unwrap();
|
||||
}
|
||||
|
||||
fn pending_tool_description(
|
||||
pending_tools: &[llm_engine::tool::ToolDefinition],
|
||||
name: &str,
|
||||
) -> String {
|
||||
pending_tools
|
||||
.iter()
|
||||
.find_map(|definition| {
|
||||
let (meta, _) = definition();
|
||||
(meta.name == name).then_some(meta.description)
|
||||
})
|
||||
.expect("tool exists")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_declares_ticket_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature(temp.path());
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(descriptor.id.to_string(), "builtin:ticket");
|
||||
assert_eq!(descriptor.runtime, FeatureRuntimeKind::Builtin);
|
||||
assert_eq!(descriptor.tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_TOOL_NAMES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_descriptor_declares_only_state_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly);
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(feature.access(), TicketFeatureAccess::ReadOnly);
|
||||
assert_eq!(descriptor.tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_can_expose_base_ticket_without_orchestration_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature_with_options(
|
||||
temp.path(),
|
||||
Some(TicketFeatureAccess::Lifecycle),
|
||||
false,
|
||||
);
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_BASE_TOOL_NAMES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_can_expose_orchestration_only_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let feature = ticket_tools_feature_with_options(temp.path(), None, true);
|
||||
let descriptor = feature.descriptor();
|
||||
assert_eq!(
|
||||
descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
TICKET_ORCHESTRATION_TOOL_NAMES
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_installation_does_not_expose_mutating_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::ReadOnly,
|
||||
))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
report.reports[0].installed_tools,
|
||||
TICKET_READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
let pending_names = pending_tools
|
||||
.iter()
|
||||
.map(|definition| definition().0.name)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(pending_names, TICKET_READ_ONLY_TOOL_NAMES);
|
||||
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
|
||||
assert!(
|
||||
!report.reports[0]
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == name)
|
||||
);
|
||||
assert!(!pending_names.iter().any(|tool| tool == name));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_companion_style_context_exposes_ticket_language_guidance() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
"#,
|
||||
);
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
let feature = ticket_tools_feature_with_access(temp.path(), TicketFeatureAccess::ReadOnly);
|
||||
let descriptor = feature.descriptor();
|
||||
let descriptor_description = descriptor
|
||||
.tools
|
||||
.iter()
|
||||
.find(|tool| tool.name == "TicketShow")
|
||||
.expect("TicketShow declared")
|
||||
.description
|
||||
.clone();
|
||||
assert!(descriptor_description.contains("Ticket record language: Japanese"));
|
||||
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(feature)
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_READ_ONLY_TOOL_NAMES.len());
|
||||
assert_eq!(
|
||||
report.reports[0].installed_tools,
|
||||
TICKET_READ_ONLY_TOOL_NAMES
|
||||
);
|
||||
let description = pending_tool_description(&pending_tools, "TicketShow");
|
||||
assert!(description.contains("Ticket record language: Japanese"));
|
||||
assert!(description.contains("distinct from worker.language"));
|
||||
assert!(description.contains("Preserve protocol literals"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_installation_exposes_lifecycle_tools() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::Lifecycle,
|
||||
))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
for name in ticket::tool::TICKET_MUTATING_TOOL_NAMES {
|
||||
assert!(
|
||||
report.reports[0]
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == name)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
report.reports[0]
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "TicketIntakeReady")
|
||||
);
|
||||
assert!(
|
||||
report.reports[0]
|
||||
.installed_tools
|
||||
.iter()
|
||||
.any(|tool| tool == "TicketWorkflowState")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_ticket_role_style_context_exposes_ticket_language_guidance() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[ticket]
|
||||
language = "Japanese"
|
||||
"#,
|
||||
);
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature_with_access(
|
||||
temp.path(),
|
||||
TicketFeatureAccess::Lifecycle,
|
||||
))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
let description = pending_tool_description(&pending_tools, "TicketComment");
|
||||
assert!(description.contains("Ticket record language: Japanese"));
|
||||
assert!(description.contains("durable Ticket record and Ticket tool body text"));
|
||||
assert!(description.contains("distinct from worker.language"));
|
||||
assert!(description.contains("memory.language"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_ticket_tools_when_default_root_is_usable() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(temp.path()))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports.len(), 1);
|
||||
assert!(report.reports[0].installed);
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
assert!(report.reports[0].skipped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installs_ticket_tools_with_configured_backend_root() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
provider = "builtin:yoi_local"
|
||||
root = "tickets"
|
||||
|
||||
[roles.coder]
|
||||
profile = "project:coder"
|
||||
"#,
|
||||
);
|
||||
make_ticket_root(&temp.path().join("tickets"));
|
||||
|
||||
let feature = ticket_tools_feature(temp.path());
|
||||
assert_eq!(feature.backend_root(), temp.path().join("tickets"));
|
||||
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(feature)
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert!(report.reports[0].diagnostics.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_ticket_config_fails_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[roles.operator]
|
||||
profile = "inherit"
|
||||
"#,
|
||||
);
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(temp.path()))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert!(pending_tools.is_empty());
|
||||
assert!(report.reports[0].installed_tools.is_empty());
|
||||
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
||||
let message = &report.reports[0].diagnostics[0].message;
|
||||
assert!(message.contains("Ticket tools not registered"));
|
||||
assert!(message.contains("unsupported Ticket role `operator`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_ticket_backend_provider_fails_closed() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
make_ticket_root(&temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH));
|
||||
write_ticket_config(
|
||||
temp.path(),
|
||||
r#"
|
||||
[backend]
|
||||
provider = "github"
|
||||
"#,
|
||||
);
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(temp.path()))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert!(pending_tools.is_empty());
|
||||
assert!(report.reports[0].installed_tools.is_empty());
|
||||
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
||||
let message = &report.reports[0].diagnostics[0].message;
|
||||
assert!(message.contains("Ticket tools not registered"));
|
||||
assert!(message.contains("unsupported Ticket backend provider `github`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn does_not_register_ticket_tools_when_root_is_missing() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(temp.path()))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert!(pending_tools.is_empty());
|
||||
assert_eq!(report.reports.len(), 1);
|
||||
assert!(report.reports[0].installed);
|
||||
assert!(report.reports[0].installed_tools.is_empty());
|
||||
assert_eq!(report.reports[0].diagnostics.len(), 1);
|
||||
assert!(
|
||||
report.reports[0].diagnostics[0]
|
||||
.message
|
||||
.contains("Ticket tools not registered")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registers_ticket_tools_for_flat_backend_root() {
|
||||
let temp = TempDir::new().unwrap();
|
||||
let root = temp.path().join(DEFAULT_TICKET_BACKEND_RELATIVE_PATH);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let mut pending_tools = Vec::new();
|
||||
let mut hooks = HookRegistryBuilder::default();
|
||||
let report = FeatureRegistryBuilder::new()
|
||||
.with_module(ticket_tools_feature(temp.path()))
|
||||
.install_into_pending(&mut pending_tools, &mut hooks);
|
||||
|
||||
assert_eq!(pending_tools.len(), TICKET_TOOL_NAMES.len());
|
||||
assert_eq!(report.reports[0].installed_tools, TICKET_TOOL_NAMES);
|
||||
assert!(report.reports[0].diagnostics.is_empty());
|
||||
assert!(!root.join("open").exists());
|
||||
assert!(!root.join("pending").exists());
|
||||
assert!(!root.join("closed").exists());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user