Auto deployments

This commit is contained in:
Alex Emmet 2026-06-13 16:12:37 +02:00
commit cc92cf681c
8 changed files with 484 additions and 6 deletions

View file

@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum AgentType { pub enum AgentType {
Planner, Planner,
@ -39,3 +39,86 @@ pub enum ApprovalMode {
Batch, Batch,
PerTool, PerTool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlannedAction {
pub tool_name: String,
pub arguments: serde_json::Value,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationResult {
pub planned_actions: Vec<PlannedAction>,
pub estimated_tokens: u32,
pub estimated_duration: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentPolicy {
pub agent_type: AgentType,
pub auto_approve: bool,
pub max_depth: u32,
pub require_preview: bool,
pub timeout_seconds: u32,
pub max_tokens: u32,
pub allowed_tools: Vec<String>,
pub forbidden_mutations: Vec<String>,
}
impl AgentPolicy {
pub fn defaults_for(agent_type: &AgentType) -> Self {
match agent_type {
AgentType::DocAgent => Self {
agent_type: agent_type.clone(),
auto_approve: true,
max_depth: 2,
require_preview: false,
timeout_seconds: 30,
max_tokens: 4096,
allowed_tools: vec!["read_file".into(), "write_file".into(), "edit_file".into()],
forbidden_mutations: vec!["Cargo.toml".into(), "package.json".into()],
},
AgentType::TestJudge => Self {
agent_type: agent_type.clone(),
auto_approve: true,
max_depth: 3,
require_preview: true,
timeout_seconds: 60,
max_tokens: 4096,
allowed_tools: vec![],
forbidden_mutations: vec![],
},
AgentType::BuildAgent => Self {
agent_type: agent_type.clone(),
auto_approve: false,
max_depth: 5,
require_preview: false,
timeout_seconds: 300,
max_tokens: 16384,
allowed_tools: vec![],
forbidden_mutations: vec!["Cargo.toml".into(), "package.json".into()],
},
AgentType::Planner => Self {
agent_type: agent_type.clone(),
auto_approve: false,
max_depth: 3,
require_preview: true,
timeout_seconds: 120,
max_tokens: 16384,
allowed_tools: vec![],
forbidden_mutations: vec![],
},
AgentType::ChatAgent => Self {
agent_type: agent_type.clone(),
auto_approve: true,
max_depth: 1,
require_preview: false,
timeout_seconds: 60,
max_tokens: 4096,
allowed_tools: vec![],
forbidden_mutations: vec![],
},
}
}
}

View file

@ -25,6 +25,7 @@ pub struct StreamEvent {
#[serde(rename = "type")] #[serde(rename = "type")]
pub event_type: String, pub event_type: String,
pub content: Option<String>, pub content: Option<String>,
pub thinking: Option<String>,
pub error: Option<String>, pub error: Option<String>,
pub message_id: Option<String>, pub message_id: Option<String>,
} }
@ -34,6 +35,17 @@ impl StreamEvent {
Self { Self {
event_type: "content".to_string(), event_type: "content".to_string(),
content: Some(content), content: Some(content),
thinking: None,
error: None,
message_id: None,
}
}
pub fn thinking(thinking: String) -> Self {
Self {
event_type: "thinking".to_string(),
content: None,
thinking: Some(thinking),
error: None, error: None,
message_id: None, message_id: None,
} }
@ -43,6 +55,7 @@ impl StreamEvent {
Self { Self {
event_type: "error".to_string(), event_type: "error".to_string(),
content: None, content: None,
thinking: None,
error: Some(error), error: Some(error),
message_id: None, message_id: None,
} }
@ -52,6 +65,7 @@ impl StreamEvent {
Self { Self {
event_type: "done".to_string(), event_type: "done".to_string(),
content: None, content: None,
thinking: None,
error: None, error: None,
message_id: Some(message_id), message_id: Some(message_id),
} }

46
src/conclusion.rs Normal file
View file

@ -0,0 +1,46 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConclusionCard {
pub id: Uuid,
pub project_id: Uuid,
pub title: String, // e.g., "Auth Module Implementation"
pub summary: String, // LLM-generated or template
pub child_run_ids: Vec<Uuid>,
pub artifacts: Vec<Artifact>, // files, docs, test results
pub metrics: ConclusionMetrics, // tokens, duration, agent counts
pub created_at: DateTime<Utc>,
pub trigger: ConclusionTrigger, // AutoChainComplete | UserMilestone | BoardFinalize | ManualMerge
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Artifact {
pub name: String,
pub path: String,
pub artifact_type: ArtifactType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ArtifactType {
File,
Documentation,
TestResult,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConclusionMetrics {
pub total_tokens: u32,
pub duration_seconds: u64,
pub agent_count: u32,
pub tool_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ConclusionTrigger {
AutoChainComplete,
UserMilestone,
BoardFinalize,
ManualMerge,
}

View file

@ -11,6 +11,7 @@ pub enum ToDoStatus {
Delegated, Delegated,
Failed, Failed,
PendingApproval, PendingApproval,
Draft,
} }
impl Default for ToDoStatus { impl Default for ToDoStatus {
@ -29,6 +30,7 @@ impl fmt::Display for ToDoStatus {
ToDoStatus::ReadyForAgent => write!(f, "ready_for_agent"), ToDoStatus::ReadyForAgent => write!(f, "ready_for_agent"),
ToDoStatus::Delegated => write!(f, "delegated"), ToDoStatus::Delegated => write!(f, "delegated"),
ToDoStatus::Failed => write!(f, "failed"), ToDoStatus::Failed => write!(f, "failed"),
ToDoStatus::Draft => write!(f, "draft"),
ToDoStatus::PendingApproval => write!(f, "pending_approval"), ToDoStatus::PendingApproval => write!(f, "pending_approval"),
} }
} }
@ -45,6 +47,7 @@ impl std::str::FromStr for ToDoStatus {
"ready_for_agent" => Ok(ToDoStatus::ReadyForAgent), "ready_for_agent" => Ok(ToDoStatus::ReadyForAgent),
"delegated" => Ok(ToDoStatus::Delegated), "delegated" => Ok(ToDoStatus::Delegated),
"failed" => Ok(ToDoStatus::Failed), "failed" => Ok(ToDoStatus::Failed),
"draft" => Ok(ToDoStatus::Draft),
"pending_approval" => Ok(ToDoStatus::PendingApproval), "pending_approval" => Ok(ToDoStatus::PendingApproval),
_ => Err(format!("unknown status: {}", s)), _ => Err(format!("unknown status: {}", s)),
} }

View file

@ -4,24 +4,29 @@ pub use uuid::Uuid;
pub mod agents; pub mod agents;
pub mod ai_response; pub mod ai_response;
pub mod conclusion;
pub mod coral; pub mod coral;
pub mod enums; pub mod enums;
pub mod errors; pub mod errors;
pub mod krill; pub mod krill;
pub mod project; pub mod project;
pub mod sync;
pub mod task; pub mod task;
pub mod todo; pub mod todo;
pub mod tools; pub mod tools;
pub use agents::{AgentType, ApprovalMode}; pub use agents::{AgentPolicy, AgentType, ApprovalMode, PlannedAction, SimulationResult};
pub use ai_response::{Conversation, Message}; pub use ai_response::{Conversation, Message};
pub use conclusion::{
Artifact, ArtifactType, ConclusionCard, ConclusionMetrics, ConclusionTrigger,
};
pub use coral::{Coral, CoralId}; pub use coral::{Coral, CoralId};
pub use enums::*; pub use enums::*;
pub use errors::{ShoalError, ValidationError}; pub use errors::{ShoalError, ValidationError};
pub use krill::{KrillConfig, KrillDescriptor, KrillId}; pub use krill::{KrillConfig, KrillDescriptor, KrillId};
pub use project::{Project, ProjectFile, ProjectId, ProjectSettings}; pub use project::{Project, ProjectFile, ProjectId, ProjectSettings};
pub use task::{Task, TaskResult}; pub use task::{Task, TaskResult};
pub use todo::ToDo; pub use todo::{Dependency, ToDo};
pub use tools::{ pub use tools::{
format_tool_error, format_tool_result, parse_tool_call_blocks, parse_tool_call_stream, format_tool_error, format_tool_result, parse_tool_call_blocks, parse_tool_call_stream,
tool_definitions, CompositionStep, ExecutionContext, MarketplaceSearchResults, ParsedToolCall, tool_definitions, CompositionStep, ExecutionContext, MarketplaceSearchResults, ParsedToolCall,

159
src/sync.rs Normal file
View file

@ -0,0 +1,159 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum ConflictResolution {
Required,
ResolvedLocal,
ResolvedRemote,
ResolvedManual,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileConflict {
pub file: String,
pub base_hash: String,
pub local_hash: String,
pub remote_hash: String,
pub resolution: ConflictResolution,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConflictManifest {
pub conflicts: Vec<FileConflict>,
pub unconflicted_remote: Vec<String>,
pub unconflicted_local: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrunkVersionSummary {
pub version_id: String,
pub parent_version: Option<String>,
pub snapshot_hash: String,
pub merged_at: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TopicState {
Working,
AwaitingReview,
Approved,
Merged,
Rejected,
Conflict,
}
impl TopicState {
pub fn as_str(&self) -> &'static str {
match self {
TopicState::Working => "working",
TopicState::AwaitingReview => "awaiting_review",
TopicState::Approved => "approved",
TopicState::Merged => "merged",
TopicState::Rejected => "rejected",
TopicState::Conflict => "conflict",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"working" => Some(TopicState::Working),
"awaiting_review" => Some(TopicState::AwaitingReview),
"approved" => Some(TopicState::Approved),
"merged" => Some(TopicState::Merged),
"rejected" => Some(TopicState::Rejected),
"conflict" => Some(TopicState::Conflict),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicSummary {
pub topic_id: String,
pub parent_topic_id: Option<String>,
pub root_task_id: String,
pub project_id: String,
pub state: TopicState,
pub trunk_version_before: Option<String>,
pub change_set_hash: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TopicChange {
pub change_id: String,
pub topic_id: String,
pub task_id: String,
pub parent_task_id: Option<String>,
pub file_path: String,
pub operation: TopicChangeOperation,
pub blob_hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum TopicChangeOperation {
Create,
Modify,
Delete,
}
impl TopicChangeOperation {
pub fn as_str(&self) -> &'static str {
match self {
TopicChangeOperation::Create => "create",
TopicChangeOperation::Modify => "modify",
TopicChangeOperation::Delete => "delete",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"create" => Some(TopicChangeOperation::Create),
"modify" => Some(TopicChangeOperation::Modify),
"delete" => Some(TopicChangeOperation::Delete),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum MergeQueueStatus {
Queued,
Merging,
Conflict,
Completed,
Deferred,
}
impl MergeQueueStatus {
pub fn as_str(&self) -> &'static str {
match self {
MergeQueueStatus::Queued => "queued",
MergeQueueStatus::Merging => "merging",
MergeQueueStatus::Conflict => "conflict",
MergeQueueStatus::Completed => "completed",
MergeQueueStatus::Deferred => "deferred",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s {
"queued" => Some(MergeQueueStatus::Queued),
"merging" => Some(MergeQueueStatus::Merging),
"conflict" => Some(MergeQueueStatus::Conflict),
"completed" => Some(MergeQueueStatus::Completed),
"deferred" => Some(MergeQueueStatus::Deferred),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergeQueueEntry {
pub request_id: String,
pub topic_id: Option<String>,
pub dock_session_id: Option<String>,
pub status: MergeQueueStatus,
pub created_at: String,
}

View file

@ -5,6 +5,7 @@ use uuid::Uuid;
use crate::enums::{ToDoSource, ToDoStatus}; use crate::enums::{ToDoSource, ToDoStatus};
use crate::errors::ValidationError; use crate::errors::ValidationError;
use crate::{CommunicationType, CommunicationValue, DataTypes, DataValue}; use crate::{CommunicationType, CommunicationValue, DataTypes, DataValue};
use std::collections::HashSet;
const MAX_TITLE_LENGTH: usize = 500; const MAX_TITLE_LENGTH: usize = 500;
const MAX_DESCRIPTION_LENGTH: usize = 10000; const MAX_DESCRIPTION_LENGTH: usize = 10000;
@ -12,14 +13,22 @@ const MIN_PRIORITY: u32 = 1;
const MAX_PRIORITY: u32 = 1000; const MAX_PRIORITY: u32 = 1000;
const DEFAULT_PRIORITY: u32 = 100; const DEFAULT_PRIORITY: u32 = 100;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Dependency {
pub todo_id: Uuid,
pub board_id: Uuid,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ToDo { pub struct ToDo {
pub id: Uuid, pub id: Uuid,
pub board_id: Uuid,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub status: ToDoStatus, pub status: ToDoStatus,
pub priority: u32, pub priority: u32,
pub depends_on: Vec<Uuid>, pub depends_on: Vec<Uuid>,
pub cross_board_depends_on: Vec<Dependency>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,
pub created_by: Uuid, pub created_by: Uuid,
@ -30,6 +39,7 @@ pub struct ToDo {
pub source: ToDoSource, pub source: ToDoSource,
pub automation_chain_id: Option<Uuid>, pub automation_chain_id: Option<Uuid>,
pub tags: Vec<String>, pub tags: Vec<String>,
pub estimated_tokens: u32,
} }
impl Default for ToDo { impl Default for ToDo {
@ -37,11 +47,13 @@ impl Default for ToDo {
let now = Utc::now(); let now = Utc::now();
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
board_id: Uuid::nil(),
title: String::new(), title: String::new(),
description: String::new(), description: String::new(),
status: ToDoStatus::default(), status: ToDoStatus::default(),
priority: DEFAULT_PRIORITY, priority: DEFAULT_PRIORITY,
depends_on: Vec::new(), depends_on: Vec::new(),
cross_board_depends_on: Vec::new(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
created_by: Uuid::nil(), created_by: Uuid::nil(),
@ -52,6 +64,7 @@ impl Default for ToDo {
source: ToDoSource::User, source: ToDoSource::User,
automation_chain_id: None, automation_chain_id: None,
tags: Vec::new(), tags: Vec::new(),
estimated_tokens: 0,
} }
} }
} }
@ -61,11 +74,13 @@ impl ToDo {
let now = Utc::now(); let now = Utc::now();
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
board_id: Uuid::nil(),
title: title.trim().to_string(), title: title.trim().to_string(),
description, description,
status: ToDoStatus::default(), status: ToDoStatus::default(),
priority: DEFAULT_PRIORITY, priority: DEFAULT_PRIORITY,
depends_on: Vec::new(), depends_on: Vec::new(),
cross_board_depends_on: Vec::new(),
created_at: now, created_at: now,
updated_at: now, updated_at: now,
created_by, created_by,
@ -76,6 +91,7 @@ impl ToDo {
source: ToDoSource::User, source: ToDoSource::User,
automation_chain_id: None, automation_chain_id: None,
tags: Vec::new(), tags: Vec::new(),
estimated_tokens: 0,
} }
} }
@ -109,12 +125,56 @@ impl ToDo {
} }
fn has_circular_dependency(&self) -> bool { fn has_circular_dependency(&self) -> bool {
if self.depends_on.is_empty() { // Direct self-dependency
return false; if self.depends_on.contains(&self.id) {
return true;
} }
false false
} }
/// Performs full graph cycle detection given a map of todo_id -> dependencies.
/// Returns the ID of the first todo that would participate in a cycle, or None.
pub fn detect_cycle_in_graph(deps: &std::collections::HashMap<Uuid, Vec<Uuid>>) -> Option<Uuid> {
let mut visited: HashSet<Uuid> = HashSet::new();
let mut in_stack: HashSet<Uuid> = HashSet::new();
fn dfs(
node: Uuid,
deps: &std::collections::HashMap<Uuid, Vec<Uuid>>,
visited: &mut HashSet<Uuid>,
in_stack: &mut HashSet<Uuid>,
) -> Option<Uuid> {
if in_stack.contains(&node) {
return Some(node);
}
if visited.contains(&node) {
return None;
}
visited.insert(node);
in_stack.insert(node);
if let Some(children) = deps.get(&node) {
for child in children {
if let Some(cycle_node) = dfs(*child, deps, visited, in_stack) {
return Some(cycle_node);
}
}
}
in_stack.remove(&node);
None
}
for node in deps.keys() {
if !visited.contains(node) {
if let Some(cycle_node) = dfs(*node, deps, &mut visited, &mut in_stack) {
return Some(cycle_node);
}
}
}
None
}
pub fn set_status(&mut self, status: ToDoStatus) -> Result<(), ValidationError> { pub fn set_status(&mut self, status: ToDoStatus) -> Result<(), ValidationError> {
let valid_transition = match (&self.status, &status) { let valid_transition = match (&self.status, &status) {
(ToDoStatus::Pending, ToDoStatus::InProgress) => true, (ToDoStatus::Pending, ToDoStatus::InProgress) => true,
@ -350,4 +410,37 @@ mod tests {
let result = todo.update(None, None, Some(0)); let result = todo.update(None, None, Some(0));
assert!(matches!(result, Err(ValidationError::InvalidPriority))); assert!(matches!(result, Err(ValidationError::InvalidPriority)));
} }
#[test]
fn test_has_circular_dependency_self_reference() {
let mut todo = ToDo::default();
todo.depends_on = vec![todo.id];
assert!(todo.has_circular_dependency());
}
#[test]
fn test_has_circular_dependency_none() {
let todo = ToDo::default();
assert!(!todo.has_circular_dependency());
}
#[test]
fn test_detect_cycle_in_graph_simple_cycle() {
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
let mut deps = std::collections::HashMap::new();
deps.insert(id_a, vec![id_b]);
deps.insert(id_b, vec![id_a]);
assert!(ToDo::detect_cycle_in_graph(&deps).is_some());
}
#[test]
fn test_detect_cycle_in_graph_no_cycle() {
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
let mut deps = std::collections::HashMap::new();
deps.insert(id_a, vec![id_b]);
deps.insert(id_b, vec![]);
assert!(ToDo::detect_cycle_in_graph(&deps).is_none());
}
} }

View file

@ -106,6 +106,20 @@ impl ToolDefinition {
} }
} }
/// Strip the board_id parameter from the tool's JSON schema.
/// This is used to hide the board context from agents that shouldn't
/// have to manage it manually (similar to project_id).
pub fn strip_board_id(&mut self) {
if let Some(obj) = self.parameters.as_object_mut() {
if let Some(properties) = obj.get_mut("properties").and_then(|p| p.as_object_mut()) {
properties.remove("board_id");
}
if let Some(required) = obj.get_mut("required").and_then(|r| r.as_array_mut()) {
required.retain(|v| v.as_str() != Some("board_id"));
}
}
}
/// Check if this tool version is deprecated /// Check if this tool version is deprecated
pub fn is_deprecated(&self) -> bool { pub fn is_deprecated(&self) -> bool {
self.deprecated self.deprecated
@ -1077,7 +1091,8 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
serde_json::json!({ serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": { "type": "string", "description": "Project ID" } "project_id": { "type": "string", "description": "Project ID" },
"board_id": { "type": "string", "description": "Board ID (defaults to project's primary board)" }
}, },
"required": ["project_id"] "required": ["project_id"]
}) })
@ -1091,6 +1106,7 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": { "type": "string", "description": "Project ID" }, "project_id": { "type": "string", "description": "Project ID" },
"board_id": { "type": "string", "description": "Board ID (defaults to project's primary board)" },
"column_id": { "type": "string", "description": "Column ID to place the todo" }, "column_id": { "type": "string", "description": "Column ID to place the todo" },
"title": { "type": "string", "description": "Title of the todo" }, "title": { "type": "string", "description": "Title of the todo" },
"description": { "type": "string", "description": "Optional description" }, "description": { "type": "string", "description": "Optional description" },
@ -1208,6 +1224,7 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
"type": "object", "type": "object",
"properties": { "properties": {
"project_id": { "type": "string", "description": "Project ID" }, "project_id": { "type": "string", "description": "Project ID" },
"board_id": { "type": "string", "description": "Board ID (defaults to project's primary board)" },
"name": { "type": "string", "description": "Column name" }, "name": { "type": "string", "description": "Column name" },
"column_order": { "type": "number", "description": "Display order" } "column_order": { "type": "number", "description": "Display order" }
}, },
@ -1516,6 +1533,7 @@ pub fn tool_definitions() -> Vec<ToolDefinition> {
"type": "object", "type": "object",
"properties": { "properties": {
"plan_id": { "type": "string", "description": "Auto-generated plan ID" }, "plan_id": { "type": "string", "description": "Auto-generated plan ID" },
"todo_id": { "type": "string", "description": "The todo ID this plan belongs to" },
"steps": { "type": "array", "description": "List of mutation steps", "items": { "steps": { "type": "array", "description": "List of mutation steps", "items": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -2016,4 +2034,61 @@ Done with tools.
assert_eq!(ToolCategory::Agent.as_str(), "agent"); assert_eq!(ToolCategory::Agent.as_str(), "agent");
assert_eq!(ToolCategory::Other.as_str(), "other"); assert_eq!(ToolCategory::Other.as_str(), "other");
} }
#[test]
fn test_streaming_rejects_tool_result_at_start() {
let chunks = vec![
"```tool-result:read_file\n",
"{\"call_id\": \"call_01\"}\n",
"```\nSome trailing text.",
];
let results = parse_tool_call_stream(chunks.into_iter());
assert!(results.is_empty(), "tool-result: at stream start should be rejected");
}
#[test]
fn test_streaming_rejects_multiple_tool_results() {
let chunks = vec![
"```tool-result:read_file\n{\"call_id\": \"c1\"}\n```\n",
"```tool-result:write_file\n{\"call_id\": \"c2\"}\n```\n",
];
let results = parse_tool_call_stream(chunks.into_iter());
assert!(results.is_empty(), "multiple tool-result: blocks should all be rejected");
}
#[test]
fn test_streaming_mixed_tool_and_tool_result() {
let chunks = vec![
"```tool:read_file\n{\"call_id\": \"c1\"}\n```\n",
"```tool-result:write_file\n{\"call_id\": \"c2\"}\n```\n",
"```tool:search_files\n{\"call_id\": \"c3\"}\n```\n",
];
let results = parse_tool_call_stream(chunks.into_iter());
assert_eq!(results.len(), 2, "should only parse the tool: blocks, not tool-result:");
assert_eq!(results[0].call_id, "c1");
assert_eq!(results[1].call_id, "c3");
}
#[test]
fn test_streaming_tool_result_only_chunk() {
let results = parse_tool_call_stream(vec!["```tool-result:read_file\n{\"call_id\": \"c1\"}\n```\n"].into_iter());
assert!(results.is_empty(), "tool-result: as only chunk content should be rejected");
}
#[test]
fn test_streaming_tool_result_in_middle_of_text() {
let chunks = vec![
"Some text before.\n```tool-result:read_file\n",
"{\"call_id\": \"c1\"}\n```\n",
"Some text after.",
];
let results = parse_tool_call_stream(chunks.into_iter());
assert!(results.is_empty(), "tool-result: in middle of text should be rejected");
}
#[test]
fn test_streaming_finish_rejects_tool_result_no_closing_fence() {
let results = parse_tool_call_stream(vec!["```tool-result:read_file\n{\"call_id\": \"c1\"}\n"].into_iter());
assert!(results.is_empty(), "tool-result: without closing fence should be rejected by finish()");
}
} }