use anyhow::Result;
use async_trait::async_trait;
use mecha_core::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
use mecha_slack::{files, Slack};
use serde_json::{json, Value};
pub struct ShowFileTool;
#[async_trait]
impl Tool for ShowFileTool {
fn name(&self) -> &str {
"show_file"
}
fn description(&self) -> &str {
"Put a file in front of the user where they can actually look at it — a chart, a \
rendered image, a PDF, a log. Use this when you have produced something whose \
point is to be *seen* rather than described, and the user is not sitting at this \
machine. **Call it in a later turn than the one that wrote the file**: tool calls \
you request together are executed at the same time, so showing a file in the same \
turn that creates it will usually find nothing there. It works only while the \
session is mirrored to a Slack thread; if it is not, say what you made and where \
it is instead."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "The file to show, relative to the workspace."
}
},
"required": ["path"]
})
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Capabilities::default().private()
}
async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let Some(raw) = input.get("path").and_then(Value::as_str) else {
return Ok(ToolOutput::err("show_file needs a `path`"));
};
let path = match ctx.resolve(raw) {
Ok(path) => path,
Err(e) => return Ok(ToolOutput::err(format!("{raw}: {e}"))),
};
let store = match crate::slack::remote::RemoteStore::open_default() {
Ok(store) => store,
Err(e) => return Ok(ToolOutput::err(format!("no remote store: {e}"))),
};
let record = match store.attached_here() {
Ok(Some(record)) => record,
Ok(None) => {
return Ok(ToolOutput::err(
"this session is not mirrored to a Slack thread, so there is nowhere to \
show it. Tell the user what you made and where it is; they can attach \
with `/remote-control <name>` if they want to see it.",
))
}
Err(e) => return Ok(ToolOutput::err(format!("could not read the store: {e}"))),
};
let cfg = match mecha_core::config::Config::load_global() {
Ok(cfg) => cfg,
Err(e) => return Ok(ToolOutput::err(format!("could not read the config: {e}"))),
};
let max_bytes = cfg.slack.max_upload_mb.saturating_mul(1024 * 1024);
let meta = match std::fs::metadata(&path) {
Ok(meta) => meta,
Err(e) => return Ok(ToolOutput::err(format!("cannot read {raw}: {e}"))),
};
let name = match crate::slack::send::vet(&path, meta.is_dir(), meta.len(), max_bytes) {
Ok(name) => name,
Err(e) => return Ok(ToolOutput::err(format!("{e:#}"))),
};
let (Some(channel), Some(thread_ts)) = (&record.channel_id, &record.thread_ts) else {
return Ok(ToolOutput::err("the attachment has no thread yet"));
};
let home = match mecha_core::work::mecha_home() {
Ok(home) => home,
Err(e) => return Ok(ToolOutput::err(format!("no mecha home: {e}"))),
};
let creds = match mecha_slack::binding::SlackStore::open(home.join("slack"))
.and_then(|s| s.credentials())
{
Ok(Some(creds)) => creds,
Ok(None) => return Ok(ToolOutput::err("no Slack tokens stored")),
Err(e) => {
return Ok(ToolOutput::err(format!(
"could not read the Slack store: {e}"
)))
}
};
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => return Ok(ToolOutput::err(format!("cannot read {raw}: {e}"))),
};
let slack = Slack::new(&creds.bot_token);
match files::upload(
&slack,
&name,
&bytes,
&files::Share {
channel_id: Some(channel),
thread_ts: Some(thread_ts),
initial_comment: None,
title: Some(&name),
},
)
.await
{
Ok(_) => Ok(ToolOutput::ok(format!(
"Shown to the user in the `{}` Slack thread as {name}.",
record.name
))),
Err(e) => Ok(ToolOutput::err(format!("could not show {name}: {e}"))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn show_file_sits_in_the_third_quadrant() {
let caps = ShowFileTool.capabilities();
assert!(caps.private_data, "it reads workspace bytes");
assert!(
!caps.external_send,
"it reaches the owner's own DM and nobody else — marking it a send \
sink would stop a tainted session showing the user its own chart"
);
assert!(
!caps.untrusted_input,
"it returns the harness's own report, not third-party content"
);
assert!(!caps.destructive, "it changes nothing");
assert!(ShowFileTool.read_only());
}
#[test]
fn the_schema_offers_no_way_to_name_a_destination() {
let schema = ShowFileTool.input_schema();
let props = schema["properties"].as_object().expect("an object schema");
assert_eq!(
props.keys().collect::<Vec<_>>(),
vec!["path"],
"show_file grew an argument; if it names a destination the tool is now a sink"
);
for banned in ["channel", "channel_id", "thread", "thread_ts", "to", "user"] {
assert!(!props.contains_key(banned), "{banned} must not be nameable");
}
}
#[tokio::test]
async fn a_missing_path_argument_is_a_recoverable_error() {
let ctx = ToolCtx::default();
let out = ShowFileTool.call(json!({}), &ctx).await.unwrap();
assert!(out.is_error);
assert!(out.content.contains("path"), "{}", out.content);
assert!(
!out.external,
"a harness refusal is not third-party content"
);
}
}