1use crate::RuntimeAgent;
15use crate::app::SlackReplyMode;
16use crate::channel::ChannelReplyMode;
17use crate::tool_types::ToolDefinition;
18use crate::tools::{Tool, ToolExecutionResult};
19use async_trait::async_trait;
20use serde::{Deserialize, Serialize};
21use serde_json::Value;
22
23pub const REPORT_PROGRESS_TOOL_NAME: &str = "report_progress";
24
25pub const CHANNEL_REPLY_MODE_TAG_PREFIX: &str = "channel:reply_mode:";
27pub const CHANNEL_REPORT_PROGRESS_ONLY_TAG: &str = "channel:reply_mode:report_progress_only";
28
29pub const SLACK_REPLY_MODE_TAG_PREFIX: &str = "slack:reply_mode:";
31pub const SLACK_REPORT_PROGRESS_ONLY_TAG: &str = "slack:reply_mode:report_progress_only";
32const REPORT_PROGRESS_PROMPT_MARKER: &str = "# External Progress Reporting";
33const REPORT_PROGRESS_SYSTEM_PROMPT: &str = r#"# External Progress Reporting
34
35This session is attached to an external handoff thread.
36
37The external user does not see normal assistant messages. They only see updates sent through `report_progress`.
38
39Rules:
40- Use `report_progress` for meaningful user-facing updates only.
41- Use status `progress` for material milestones, `blocked` when waiting or stuck, and `completed` before the turn ends.
42- Keep summaries concise, deterministic, and focused on outcomes.
43- Do not mirror low-level tool chatter into `report_progress`."#;
44
45#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum ProgressReportStatus {
48 Progress,
49 Blocked,
50 Completed,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54pub struct ProgressReportPayload {
55 pub status: ProgressReportStatus,
56 pub summary: String,
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub details: Vec<String>,
59}
60
61impl ProgressReportPayload {
62 fn validate(self) -> Result<Self, String> {
63 let summary = self.summary.trim();
64 if summary.is_empty() {
65 return Err("Missing required field: 'summary'".to_string());
66 }
67
68 let mut details = Vec::with_capacity(self.details.len());
69 for (idx, detail) in self.details.iter().enumerate() {
70 let trimmed = detail.trim();
71 if trimmed.is_empty() {
72 return Err(format!("details[{}] must not be empty", idx));
73 }
74 details.push(trimmed.to_string());
75 }
76
77 Ok(Self {
78 status: self.status,
79 summary: summary.to_string(),
80 details,
81 })
82 }
83}
84
85pub fn session_uses_report_progress(tags: &[String]) -> bool {
88 tags.iter()
89 .any(|tag| tag == CHANNEL_REPORT_PROGRESS_ONLY_TAG || tag == SLACK_REPORT_PROGRESS_ONLY_TAG)
90}
91
92pub fn sync_channel_reply_mode_tags(tags: &mut Vec<String>, reply_mode: ChannelReplyMode) {
95 tags.retain(|tag| !tag.starts_with(CHANNEL_REPLY_MODE_TAG_PREFIX));
96 if reply_mode == ChannelReplyMode::ReportProgressOnly {
97 tags.push(CHANNEL_REPORT_PROGRESS_ONLY_TAG.to_string());
98 }
99}
100
101pub fn sync_slack_reply_mode_tags(tags: &mut Vec<String>, reply_mode: SlackReplyMode) {
104 tags.retain(|tag| !tag.starts_with(SLACK_REPLY_MODE_TAG_PREFIX));
105 if reply_mode == SlackReplyMode::ReportProgressOnly {
106 tags.push(SLACK_REPORT_PROGRESS_ONLY_TAG.to_string());
107 }
108 sync_channel_reply_mode_tags(tags, reply_mode.into());
110}
111
112pub fn report_progress_tool_definition() -> ToolDefinition {
113 ReportProgressTool.to_definition()
114}
115
116pub fn apply_report_progress_mode(mut runtime_agent: RuntimeAgent) -> RuntimeAgent {
117 if !runtime_agent
118 .tools
119 .iter()
120 .any(|tool| tool.name() == REPORT_PROGRESS_TOOL_NAME)
121 {
122 runtime_agent.tools.push(report_progress_tool_definition());
123 }
124
125 if !runtime_agent
126 .system_prompt
127 .contains(REPORT_PROGRESS_PROMPT_MARKER)
128 {
129 runtime_agent.system_prompt = if runtime_agent.system_prompt.is_empty() {
130 REPORT_PROGRESS_SYSTEM_PROMPT.to_string()
131 } else {
132 format!(
133 "{}\n\n{}",
134 REPORT_PROGRESS_SYSTEM_PROMPT, runtime_agent.system_prompt
135 )
136 };
137 }
138
139 runtime_agent
140}
141
142pub fn format_progress_report(report: &ProgressReportPayload) -> String {
145 let heading = match report.status {
146 ProgressReportStatus::Progress => "Update",
147 ProgressReportStatus::Blocked => "Blocked",
148 ProgressReportStatus::Completed => "Done",
149 };
150
151 let mut lines = vec![format!("{}: {}", heading, report.summary)];
152 for detail in &report.details {
153 lines.push(format!("- {}", detail));
154 }
155 lines.join("\n")
156}
157
158pub fn format_progress_report_for_slack(report: &ProgressReportPayload) -> String {
160 let heading = match report.status {
161 ProgressReportStatus::Progress => "Update",
162 ProgressReportStatus::Blocked => "Blocked",
163 ProgressReportStatus::Completed => "Done",
164 };
165
166 let mut lines = vec![format!("{}: {}", heading, report.summary)];
167 for detail in &report.details {
168 lines.push(format!("- {}", detail));
169 }
170 lines.join("\n")
171}
172
173pub struct ReportProgressTool;
174
175#[async_trait]
176impl Tool for ReportProgressTool {
177 fn name(&self) -> &str {
178 REPORT_PROGRESS_TOOL_NAME
179 }
180
181 fn display_name(&self) -> Option<&str> {
182 Some("Report Progress")
183 }
184
185 fn description(&self) -> &str {
186 "Send a deterministic, user-facing progress update for an external handoff thread. Use status 'completed' before ending the turn."
187 }
188
189 fn parameters_schema(&self) -> Value {
190 serde_json::json!({
191 "type": "object",
192 "properties": {
193 "status": {
194 "type": "string",
195 "enum": ["progress", "blocked", "completed"],
196 "description": "Kind of progress update being reported."
197 },
198 "summary": {
199 "type": "string",
200 "minLength": 1,
201 "description": "Short user-facing summary of the current milestone or outcome."
202 },
203 "details": {
204 "type": "array",
205 "description": "Optional short bullet points with concrete outcomes or blockers.",
206 "items": {
207 "type": "string",
208 "minLength": 1
209 }
210 }
211 },
212 "required": ["status", "summary"],
213 "additionalProperties": false
214 })
215 }
216
217 async fn execute(&self, arguments: Value) -> ToolExecutionResult {
218 let payload = match serde_json::from_value::<ProgressReportPayload>(arguments) {
219 Ok(payload) => payload,
220 Err(error) => {
221 return ToolExecutionResult::tool_error(format!(
222 "Invalid report_progress arguments: {}",
223 error
224 ));
225 }
226 };
227
228 match payload.validate() {
229 Ok(validated) => ToolExecutionResult::success(serde_json::to_value(validated).unwrap()),
230 Err(error) => ToolExecutionResult::tool_error(error),
231 }
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[tokio::test]
240 async fn test_report_progress_tool_success() {
241 let tool = ReportProgressTool;
242 let result = tool
243 .execute(serde_json::json!({
244 "status": "completed",
245 "summary": "Fixed the failing Slack session routing",
246 "details": ["added reply-mode tags", "wired deterministic progress delivery"]
247 }))
248 .await;
249
250 match result {
251 ToolExecutionResult::Success(value) => {
252 let payload: ProgressReportPayload = serde_json::from_value(value).unwrap();
253 assert_eq!(payload.status, ProgressReportStatus::Completed);
254 assert_eq!(payload.summary, "Fixed the failing Slack session routing");
255 assert_eq!(payload.details.len(), 2);
256 }
257 other => panic!("Expected success, got {:?}", other),
258 }
259 }
260
261 #[tokio::test]
262 async fn test_report_progress_tool_rejects_empty_summary() {
263 let tool = ReportProgressTool;
264 let result = tool
265 .execute(serde_json::json!({
266 "status": "progress",
267 "summary": " "
268 }))
269 .await;
270
271 match result {
272 ToolExecutionResult::ToolError(message) => {
273 assert_eq!(message, "Missing required field: 'summary'");
274 }
275 other => panic!("Expected tool error, got {:?}", other),
276 }
277 }
278
279 #[test]
280 fn test_sync_slack_reply_mode_tags() {
281 let mut tags = vec![
282 "slack:app:app_123".to_string(),
283 "slack:thread:123.456".to_string(),
284 "slack:reply_mode:all_messages".to_string(),
285 ];
286
287 sync_slack_reply_mode_tags(&mut tags, SlackReplyMode::ReportProgressOnly);
288
289 assert!(tags.iter().any(|tag| tag == SLACK_REPORT_PROGRESS_ONLY_TAG));
291 assert!(
292 tags.iter()
293 .any(|tag| tag == CHANNEL_REPORT_PROGRESS_ONLY_TAG)
294 );
295 assert_eq!(
296 tags.iter()
297 .filter(|tag| tag.starts_with(SLACK_REPLY_MODE_TAG_PREFIX))
298 .count(),
299 1
300 );
301 }
302
303 #[test]
304 fn test_sync_channel_reply_mode_tags() {
305 let mut tags = vec!["other:tag".to_string()];
306
307 sync_channel_reply_mode_tags(&mut tags, ChannelReplyMode::ReportProgressOnly);
308 assert!(
309 tags.iter()
310 .any(|tag| tag == CHANNEL_REPORT_PROGRESS_ONLY_TAG)
311 );
312
313 sync_channel_reply_mode_tags(&mut tags, ChannelReplyMode::AllMessages);
314 assert!(
315 !tags
316 .iter()
317 .any(|tag| tag.starts_with(CHANNEL_REPLY_MODE_TAG_PREFIX))
318 );
319 }
320
321 #[test]
322 fn test_session_uses_report_progress_generic_tag() {
323 let tags = vec![CHANNEL_REPORT_PROGRESS_ONLY_TAG.to_string()];
324 assert!(session_uses_report_progress(&tags));
325 }
326
327 #[test]
328 fn test_session_uses_report_progress_legacy_tag() {
329 let tags = vec![SLACK_REPORT_PROGRESS_ONLY_TAG.to_string()];
330 assert!(session_uses_report_progress(&tags));
331 }
332
333 #[test]
334 fn test_apply_report_progress_mode_adds_tool_and_prompt_once() {
335 let runtime_agent = RuntimeAgent::new("Base prompt.", "gpt-5.4");
336
337 let runtime_agent = apply_report_progress_mode(runtime_agent);
338 let runtime_agent = apply_report_progress_mode(runtime_agent);
339
340 assert_eq!(
341 runtime_agent
342 .tools
343 .iter()
344 .filter(|tool| tool.name() == REPORT_PROGRESS_TOOL_NAME)
345 .count(),
346 1
347 );
348 assert_eq!(
349 runtime_agent
350 .system_prompt
351 .matches(REPORT_PROGRESS_PROMPT_MARKER)
352 .count(),
353 1
354 );
355 }
356
357 #[test]
358 fn test_format_progress_report_generic() {
359 let text = format_progress_report(&ProgressReportPayload {
360 status: ProgressReportStatus::Progress,
361 summary: "Deploying to staging".to_string(),
362 details: vec!["built image".to_string(), "pushing to registry".to_string()],
363 });
364 assert_eq!(
365 text,
366 "Update: Deploying to staging\n- built image\n- pushing to registry"
367 );
368
369 let text_no_details = format_progress_report(&ProgressReportPayload {
370 status: ProgressReportStatus::Completed,
371 summary: "All done".to_string(),
372 details: vec![],
373 });
374 assert_eq!(text_no_details, "Done: All done");
375 }
376
377 #[test]
378 fn test_format_progress_report_for_slack() {
379 let text = format_progress_report_for_slack(&ProgressReportPayload {
380 status: ProgressReportStatus::Blocked,
381 summary: "Waiting on credentials".to_string(),
382 details: vec!["need Slack bot token".to_string()],
383 });
384
385 assert_eq!(
386 text,
387 "Blocked: Waiting on credentials\n- need Slack bot token"
388 );
389 }
390}