use agentic_tools_core::fmt::TextFormat;
use agentic_tools_core::fmt::TextOptions;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use std::fmt::Write;
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct OrchestratorRunInput {
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub message: Option<String>,
#[serde(default)]
pub wait_for_activity: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RunStatus {
Completed,
PermissionRequired,
QuestionRequired,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QuestionOptionView {
pub label: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QuestionInfoView {
pub question: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub header: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<QuestionOptionView>,
#[serde(default)]
pub multiple: bool,
#[serde(default)]
pub custom: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct OrchestratorRunOutput {
pub session_id: String,
pub status: RunStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub response: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partial_response: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permission_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub permission_patterns: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub question_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub questions: Vec<QuestionInfoView>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
impl TextFormat for OrchestratorRunOutput {
fn fmt_text(&self, _opts: &TextOptions) -> String {
let mut out = String::new();
let status_icon = match self.status {
RunStatus::Completed => "\u{2713}", RunStatus::PermissionRequired => "\u{23f8}", RunStatus::QuestionRequired => "?",
};
let status_str = match self.status {
RunStatus::Completed => "completed",
RunStatus::PermissionRequired => "permission_required",
RunStatus::QuestionRequired => "question_required",
};
let _ = writeln!(
out,
"{status_icon} session {status_str} {}",
self.session_id
);
for warning in &self.warnings {
let _ = writeln!(out, " warning: {warning}");
}
if self.status == RunStatus::PermissionRequired {
out.push_str("\n--- Permission Request ---\n");
if let Some(ptype) = &self.permission_type {
let _ = writeln!(out, "Type: {ptype}");
}
if !self.permission_patterns.is_empty() {
let _ = writeln!(out, "Patterns: {}", self.permission_patterns.join(", "));
}
if let Some(req_id) = &self.permission_request_id {
let _ = writeln!(out, "Request ID: {req_id}");
}
out.push_str("\nTo respond: orchestrator_respond_permission(session_id, reply)\n");
out.push_str(" reply options: once | always | reject\n");
out.push_str(" tip: include permission_request_id=<Request ID> when provided\n");
}
if self.status == RunStatus::QuestionRequired {
out.push_str("\n--- Question Request ---\n");
if let Some(req_id) = &self.question_request_id {
let _ = writeln!(out, "Request ID: {req_id}");
}
for (index, question) in self.questions.iter().enumerate() {
if !question.header.is_empty() {
let _ = writeln!(out, "Header: {}", question.header);
}
let _ = writeln!(out, "Question {}: {}", index + 1, question.question);
for option in &question.options {
if option.description.is_empty() {
let _ = writeln!(out, " - {}", option.label);
} else {
let _ = writeln!(out, " - {}: {}", option.label, option.description);
}
}
let _ = writeln!(out, " multiple: {}", question.multiple);
let _ = writeln!(out, " custom: {}", question.custom);
}
out.push_str(
"\nTo respond: orchestrator_respond_question(session_id, action, answers)\n",
);
out.push_str(" action options: reply | reject\n");
out.push_str(" tip: include question_request_id=<Request ID> when provided\n");
}
if let Some(response) = &self.response {
out.push_str("\n--- Response ---\n");
out.push_str(response);
} else if let Some(partial) = &self.partial_response
&& !partial.trim().is_empty()
{
out.push_str("\n--- Partial Response ---\n");
out.push_str(partial);
}
out
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct ListSessionsInput {
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SessionSummary {
pub id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub updated: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub created: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<SessionStatusSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub change_stats: Option<ChangeStats>,
pub launched_by_you: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "state", rename_all = "lowercase")]
pub enum SessionStatusSummary {
Idle,
Busy,
Retry {
attempt: u64,
message: String,
next: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ChangeStats {
pub additions: u64,
pub deletions: u64,
pub files: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GetSessionStateInput {
pub session_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GetSessionStateOutput {
pub session_id: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub directory: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
pub status: SessionStatusSummary,
pub launched_by_you: bool,
pub pending_message_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_activity: Option<i64>,
pub recent_tool_calls: Vec<ToolCallSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ToolCallSummary {
pub call_id: String,
pub tool_name: String,
pub state: ToolStateSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub started_at: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub completed_at: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "status", rename_all = "lowercase")]
pub enum ToolStateSummary {
Pending,
Running,
Completed,
Error { message: String },
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ListSessionsOutput {
pub sessions: Vec<SessionSummary>,
}
impl TextFormat for ListSessionsOutput {
fn fmt_text(&self, _opts: &TextOptions) -> String {
let mut out = format!("Sessions ({}):\n", self.sessions.len());
for s in &self.sessions {
let age = s
.updated
.map_or_else(|| "unknown".to_string(), format_time_ago);
let status = match &s.status {
Some(SessionStatusSummary::Idle) => "idle",
Some(SessionStatusSummary::Busy) => "busy",
Some(SessionStatusSummary::Retry { .. }) => "retry",
None => "unknown",
};
let launched = if s.launched_by_you {
" [launched by you]"
} else {
""
};
let _ = writeln!(out, " {} - {} ({age}, {status}){launched}", s.id, s.title);
if let Some(path) = &s.path {
let _ = writeln!(out, " Path: {path}");
}
}
if self.sessions.is_empty() {
out.push_str(" (no sessions found)\n");
}
out
}
}
impl TextFormat for GetSessionStateOutput {
fn fmt_text(&self, _opts: &TextOptions) -> String {
let mut out = format!("Session: {} ({})\n", self.session_id, self.title);
if let Some(dir) = &self.directory {
let _ = writeln!(out, " Directory: {dir}");
}
if let Some(path) = &self.path {
let _ = writeln!(out, " Path: {path}");
}
let status_str = match &self.status {
SessionStatusSummary::Idle => "Idle".to_string(),
SessionStatusSummary::Busy => "Busy".to_string(),
SessionStatusSummary::Retry {
attempt,
message,
next,
} => format!("Retry (attempt {attempt}, next at {next}, reason: {message})"),
};
let _ = writeln!(out, " Status: {status_str}");
let _ = writeln!(out, " Launched by you: {}", self.launched_by_you);
let _ = writeln!(out, " Pending messages: {}", self.pending_message_count);
if let Some(last) = self.last_activity {
let _ = writeln!(out, " Last activity: {}", format_time_ago(last));
}
if !self.recent_tool_calls.is_empty() {
out.push_str(" Recent tool calls:\n");
for tool_call in &self.recent_tool_calls {
let state_str = match &tool_call.state {
ToolStateSummary::Pending => "pending".to_string(),
ToolStateSummary::Running => "running".to_string(),
ToolStateSummary::Completed => "completed".to_string(),
ToolStateSummary::Error { message } => format!("error: {message}"),
};
let _ = writeln!(
out,
" {} ({}) - {}",
tool_call.call_id, tool_call.tool_name, state_str
);
}
}
out
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
pub struct ListCommandsInput {}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CommandInfo {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ListCommandsOutput {
pub commands: Vec<CommandInfo>,
}
impl TextFormat for ListCommandsOutput {
fn fmt_text(&self, _opts: &TextOptions) -> String {
let mut out = String::from("Available commands:\n");
for cmd in &self.commands {
let _ = write!(out, " {}", cmd.name);
if let Some(desc) = &cmd.description {
if let Some(first_line) = desc.lines().next() {
let _ = write!(out, " - {first_line}");
}
}
out.push('\n');
}
if self.commands.is_empty() {
out.push_str(" (no commands available)\n");
}
out.push_str("\nUse orchestrator_run(command=<name>, message=<args>) to execute\n");
out
}
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct RespondPermissionInput {
pub session_id: String,
#[serde(default)]
pub permission_request_id: Option<String>,
pub reply: PermissionReply,
#[serde(default)]
pub message: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum PermissionReply {
Once,
Always,
Reject,
}
pub type RespondPermissionOutput = OrchestratorRunOutput;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum QuestionAction {
Reply,
Reject,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct RespondQuestionInput {
pub session_id: String,
#[serde(default)]
pub question_request_id: Option<String>,
pub action: QuestionAction,
#[serde(default)]
pub answers: Vec<Vec<String>>,
}
pub type RespondQuestionOutput = OrchestratorRunOutput;
fn format_time_ago(unix_ts: i64) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let diff = now - unix_ts;
if diff < 60 {
"just now".to_string()
} else if diff < 3600 {
format!("{}m ago", diff / 60)
} else if diff < 86400 {
format!("{}h ago", diff / 3600)
} else {
format!("{}d ago", diff / 86400)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn run_output_text_format_completed() {
let out = OrchestratorRunOutput {
session_id: "sess-123".into(),
status: RunStatus::Completed,
response: Some("Task completed successfully.".into()),
partial_response: None,
permission_request_id: None,
permission_type: None,
permission_patterns: vec![],
question_request_id: None,
questions: vec![],
warnings: vec![],
};
let text = out.fmt_text(&TextOptions::default());
assert!(text.contains("completed"));
assert!(text.contains("sess-123"));
assert!(text.contains("Task completed successfully"));
}
#[test]
fn run_output_text_format_permission_required() {
let out = OrchestratorRunOutput {
session_id: "sess-456".into(),
status: RunStatus::PermissionRequired,
response: None,
partial_response: Some("Starting task...".into()),
permission_request_id: Some("perm-789".into()),
permission_type: Some("file.write".into()),
permission_patterns: vec!["src/**/*.rs".into()],
question_request_id: None,
questions: vec![],
warnings: vec![],
};
let text = out.fmt_text(&TextOptions::default());
assert!(text.contains("permission_required"));
assert!(text.contains("sess-456"));
assert!(text.contains("file.write"));
assert!(text.contains("src/**/*.rs"));
assert!(text.contains("perm-789"));
assert!(text.contains("orchestrator_respond_permission"));
assert!(text.contains("permission_request_id"));
}
#[test]
fn run_output_text_format_question_required() {
let out = OrchestratorRunOutput {
session_id: "sess-789".into(),
status: RunStatus::QuestionRequired,
response: None,
partial_response: Some("Need confirmation".into()),
permission_request_id: None,
permission_type: None,
permission_patterns: vec![],
question_request_id: Some("question-123".into()),
questions: vec![QuestionInfoView {
question: "Continue with deploy?".into(),
header: "Deployment".into(),
options: vec![QuestionOptionView {
label: "yes".into(),
description: "Proceed".into(),
}],
multiple: false,
custom: false,
}],
warnings: vec![],
};
let text = out.fmt_text(&TextOptions::default());
assert!(text.contains("question_required"));
assert!(text.contains("question-123"));
assert!(text.contains("Continue with deploy?"));
assert!(text.contains("orchestrator_respond_question"));
}
#[test]
fn list_sessions_text_format() {
let out = ListSessionsOutput {
sessions: vec![SessionSummary {
id: "sess-1".into(),
title: "Research caching".into(),
updated: Some(0),
created: Some(0),
directory: Some("/tmp/project".into()),
path: Some("src/cache.rs".into()),
status: Some(SessionStatusSummary::Idle),
change_stats: Some(ChangeStats {
additions: 1,
deletions: 0,
files: 1,
}),
launched_by_you: true,
}],
};
let text = out.fmt_text(&TextOptions::default());
assert!(text.contains("Sessions (1)"));
assert!(text.contains("sess-1"));
assert!(text.contains("Research caching"));
assert!(text.contains("idle"));
assert!(text.contains("launched by you"));
assert!(text.contains("Path: src/cache.rs"));
}
#[test]
fn get_session_state_text_format_includes_path() {
let out = GetSessionStateOutput {
session_id: "sess-1".into(),
title: "Research caching".into(),
directory: Some("/tmp/project".into()),
path: Some("src/cache.rs".into()),
status: SessionStatusSummary::Idle,
launched_by_you: false,
pending_message_count: 0,
last_activity: None,
recent_tool_calls: vec![],
};
let text = out.fmt_text(&TextOptions::default());
assert!(text.contains("Directory: /tmp/project"));
assert!(text.contains("Path: src/cache.rs"));
}
#[test]
fn list_commands_text_format() {
let out = ListCommandsOutput {
commands: vec![CommandInfo {
name: "research".into(),
description: Some("Run research workflow".into()),
}],
};
let text = out.fmt_text(&TextOptions::default());
assert!(text.contains("research"));
assert!(text.contains("Run research workflow"));
assert!(text.contains("orchestrator_run"));
}
}