use std::ffi::{OsStr, OsString};
use std::sync::atomic::{AtomicU64, Ordering};
use libtmux::{Command, CommandChain, Error, NewSessionOptions};
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::{tool, tool_router};
use crate::{
ChannelArgs, ChannelSignal, CreateSessionArgs, Killed, Layout, PaneArgs, PaneChanged, PaneView,
PasteTextArgs, Pasted, ResizePaneArgs, SelectLayoutArgs, SelectPaneArgs, SelectWindowArgs,
SendKeysArgs, Sent, SessionArgs, SessionView, Size, TmuxTools, WindowArgs, Windows,
};
use super::error::{EffectBoundary, ToolError, bad_input, tmux_error, vanished};
use super::lossy;
use super::pane_input::{MissingSource, PaneInputReach, active_run_error};
static PASTE_BUFFER_COUNTER: AtomicU64 = AtomicU64::new(0);
const INTERRUPT_KEYS: [&str; 2] = ["C-c", "C-\\"];
fn literal_input(pane: &str, text: String) -> Command {
Command::new("send-keys")
.arg("-t")
.arg(pane)
.arg("-l")
.arg("--")
.sensitive_arg(OsString::from(text))
}
fn named_input(pane: &str, keys: Vec<String>, enter: bool) -> Command {
let mut command = Command::new("send-keys").arg("-t").arg(pane).arg("--");
for key in keys {
command = command.arg(key);
}
if enter {
command = command.arg("Enter");
}
command
}
fn input_dispatch(
pane: &str,
text: Option<String>,
keys: Vec<String>,
enter: bool,
) -> Option<CommandChain> {
let mut commands = Vec::with_capacity(2);
if let Some(text) = text {
commands.push(literal_input(pane, text));
}
if !keys.is_empty() || enter {
commands.push(named_input(pane, keys, enter));
}
let mut commands = commands.into_iter();
let first = commands.next()?;
Some(commands.fold(CommandChain::new(first), CommandChain::then))
}
async fn delete_private_paste_buffer(server: &libtmux::Server, name: &str) -> Result<(), Error> {
if server.buffer(name).await?.is_none() {
return Ok(());
}
server.delete_buffer(name).await
}
fn cleanup_after_refusal(primary: ToolError, cleanup: Result<(), Error>) -> ToolError {
match cleanup {
Ok(()) => primary,
Err(cleanup) => {
let message = primary.into_error_data().message;
let mut boundary = EffectBoundary::new("paste_text");
boundary.mark();
boundary.local(format!(
"{message}; temporary paste buffer cleanup failed: {cleanup}"
))
}
}
}
impl TmuxTools {
pub(super) async fn protect_window_caller(
&self,
window: &libtmux::Window,
) -> Result<(), ToolError> {
let Some(own) = self.protected_pane().await? else {
return Ok(());
};
let panes = window.panes().await.map_err(|e| tmux_error(&e))?;
if panes.iter().any(|pane| pane.id().to_string() == own) {
return Err(Self::self_harm("window", own));
}
Ok(())
}
async fn protect_session_caller(&self, session: &libtmux::Session) -> Result<(), ToolError> {
let Some(own) = self.protected_pane().await? else {
return Ok(());
};
let panes = session.panes().await.map_err(|e| tmux_error(&e))?;
if panes.iter().any(|pane| pane.id().to_string() == own) {
return Err(Self::self_harm("session", own));
}
Ok(())
}
pub(crate) async fn send_keys_one(
&self,
SendKeysArgs {
pane,
text,
keys,
enter,
}: SendKeysArgs,
) -> Result<Json<Sent>, ToolError> {
let keys = keys.unwrap_or_default();
if text.is_none() && keys.is_empty() && !enter {
return Err(bad_input("send_keys needs text, keys, or enter".to_owned()));
}
let interrupt = text.is_none()
&& !enter
&& keys
.iter()
.all(|key| INTERRUPT_KEYS.contains(&key.as_str()));
let reach = PaneInputReach::Synchronized;
let missing = MissingSource::CallerInput;
let (initial, reservation, plan) = if interrupt {
let initial = self.preflight_interrupt(&pane, reach, missing).await?;
let plan = self.preflight_interrupt(&pane, reach, missing).await?;
(initial, None, plan)
} else {
let initial = self.preflight_pane_input(&pane, reach, missing).await?;
let reservation = initial
.reserve()
.ok_or_else(|| active_run_error(initial.target.id().as_ref()))?;
let plan = self
.preflight_reserved_pane_input(&pane, reach, missing, &reservation)
.await?;
(initial, Some(reservation), plan)
};
if !initial.same_authority(&plan)
|| reservation
.as_ref()
.is_some_and(|reservation| !plan.owns(reservation))
{
return Err(bad_input(format!(
"pane {pane} changed its configured input authority before send dispatch"
)));
}
let mut tracked_keys = keys.clone();
if enter {
tracked_keys.push("Enter".to_owned());
}
let echo_update = self.echoes.apply(
plan.generation,
&plan.endpoint,
&plan.configured,
text.as_deref(),
&tracked_keys,
);
let dispatch = input_dispatch(plan.target.id().as_ref(), text, keys, enter)
.ok_or_else(|| bad_input("send_keys needs text, keys, or enter".to_owned()))?;
let mut boundary = EffectBoundary::new("send_keys");
if dispatch.command_count() > 1 {
boundary.mark();
}
let result = match self.server.chain(dispatch).await {
Ok(result) => result,
Err(error) => {
self.echoes.abandon(echo_update);
return Err(boundary.error(error));
}
};
if let Some(error) = result.refusal_for("send-keys") {
self.echoes.abandon(echo_update);
return Err(boundary.error(error));
}
self.echoes.commit(echo_update);
Ok(Json(Sent {
pane: plan.target.id().to_string(),
panes: plan.configured,
}))
}
}
#[tool_router(router = control_router, vis = "pub(super)")]
impl TmuxTools {
#[tool(
description = "Kill a window, closing it in every session that links it.",
title = "Kill Window",
meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, {
"window" => [TmuxLookup]
}; idempotent)
)]
pub async fn kill_window(
&self,
Parameters(WindowArgs { window }): Parameters<WindowArgs>,
) -> Result<Json<Killed>, ToolError> {
let window = self.find_window(&window).await?;
let id = window.id().to_string();
self.protect_window_caller(&window).await?;
window.kill().await.map_err(|e| tmux_error(&e))?;
Ok(Json(Killed { id }))
}
#[tool(
description = "Kill a pane. Killing a window's last pane closes the window.",
title = "Kill Pane",
meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, {
"pane" => [TmuxLookup]
}; idempotent)
)]
pub async fn kill_pane(
&self,
Parameters(PaneArgs { pane }): Parameters<PaneArgs>,
) -> Result<Json<Killed>, ToolError> {
let pane = self.find_pane(&pane).await?;
let id = pane.id().to_string();
if self.protected_pane().await? == Some(id.as_str()) {
return Err(Self::self_harm("pane", &id));
}
pane.kill().await.map_err(|e| tmux_error(&e))?;
Ok(Json(Killed { id }))
}
#[tool(
description = "Create a new detached tmux session.",
title = "Create Session",
meta = crate::capability_meta!(
Execute, ConfiguredProcess,
effects = [Change],
outputs = [TmuxMetadata],
secrets = true,
untrusted = true,
sinks = {
"name" => [TmuxState, TmuxFormat],
"start_directory" => [TmuxState, TmuxFormat]
},
literalized = ["name", "start_directory"],
nested = [],
self_bounded = false,
always_load = false,
)
)]
pub async fn create_session(
&self,
Parameters(CreateSessionArgs {
name,
start_directory,
}): Parameters<CreateSessionArgs>,
) -> Result<Json<SessionView>, ToolError> {
let mut options = NewSessionOptions::new(name);
if let Some(directory) = start_directory {
options = options.start_directory(directory);
}
let session = self
.server
.new_session(options)
.await
.map_err(|e| tmux_error(&e))?;
let foreign_attached = self.foreign_attached_sessions().await;
Ok(Json(SessionView {
id: session.id().to_string(),
name: lossy(session.name()),
windows: session.window_count(),
attached: foreign_attached.as_ref().map_or_else(
|| session.is_attached(),
|set| set.contains(&session.id().to_string()),
),
}))
}
#[tool(
description = "Kill a tmux session and everything in it.",
title = "Kill Session",
meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, {
"session" => [TmuxLookup]
}; idempotent)
)]
pub async fn kill_session(
&self,
Parameters(SessionArgs { session }): Parameters<SessionArgs>,
) -> Result<Json<Killed>, ToolError> {
let target = self.find_session(&session).await?;
let id = target.id().to_string();
self.protect_session_caller(&target).await?;
target.kill().await.map_err(|e| tmux_error(&e))?;
Ok(Json(Killed { id }))
}
#[tool(
description = "Move one edge of a pane by a number of rows or columns.",
title = "Resize Pane",
meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, {
"pane" => [TmuxLookup],
"direction" => [TmuxState],
"cells" => [TmuxState]
})
)]
pub async fn resize_pane(
&self,
Parameters(ResizePaneArgs {
pane,
direction,
cells,
}): Parameters<ResizePaneArgs>,
) -> Result<Json<Size>, ToolError> {
let direction = crate::schema::resize_direction(&direction).ok_or_else(|| {
bad_input(format!(
"direction must be {}, not {direction}",
crate::schema::words_or(&crate::schema::resize_direction_words())
))
})?;
let mut pane = self.find_pane(&pane).await?;
pane.resize_by(direction, cells)
.await
.map_err(|e| tmux_error(&e))?;
Ok(Json(Size {
pane: pane.id().to_string(),
width: pane.width(),
height: pane.height(),
}))
}
#[tool(
description = "Type text into a pane, press named keys in it, or both. `text` is sent \
literally, so C-c in it types those three characters. Use `keys` for \
anything without a character of its own -- C-c to interrupt a running \
command, Escape, Up, C-d -- which are tmux key names and are \
interpreted. Text, keys, and optional Enter keep that order in one tmux \
dispatch. Before input, the configured synchronized-pane cohort is \
observed; a dead, \
input-disabled, mode-owned, terminal-attended, or inherited-caller member \
refuses the whole call, and so does an active run_shell_command, except \
for keys C-c or C-\\ sent alone, which interrupt it. Returned pane IDs \
describe configured membership, not confirmed delivery. The \
observation can race with tmux processing the input. A submitted line is \
remembered briefly so wait_for_text can discount its own echo; an \
unrecognized key stops that for the pane's current line rather than mask \
it inaccurately.",
title = "Send Keys To Pane",
meta = crate::capability_meta!(Execute, PaneInput, [Change], [TmuxMetadata], true, true, {
"pane" => [TmuxLookup],
"text" => [PaneInput],
"keys" => [PaneInput],
"enter" => [PaneInput]
})
)]
pub async fn send_keys(
&self,
Parameters(args): Parameters<SendKeysArgs>,
) -> Result<Json<Sent>, ToolError> {
self.send_keys_one(args).await
}
#[tool(
description = "Select a pane, making it its window's active pane. Give a direction to \
move relative to it instead: up, down, left, and right follow the \
layout, last returns to the previously active pane, and next and \
previous step through the window in order.",
title = "Select Pane",
meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, {
"pane" => [TmuxLookup],
"direction" => [TmuxState]
})
)]
pub async fn select_pane(
&self,
Parameters(SelectPaneArgs { pane, direction }): Parameters<SelectPaneArgs>,
) -> Result<Json<PaneView>, ToolError> {
let target = self.find_pane(&pane).await?;
let selected = match direction.as_deref() {
Some("next" | "previous") => {
let panes = self
.find_window(target.window_id().as_ref())
.await?
.panes()
.await
.map_err(|e| tmux_error(&e))?;
let at = panes
.iter()
.position(|candidate| candidate.id() == target.id())
.ok_or_else(|| vanished("the pane vanished from its own window"))?;
let step = if matches!(direction.as_deref(), Some("next")) {
at + 1
} else {
at + panes.len() - 1
};
let mut chosen = panes
.get(step % panes.len())
.cloned()
.ok_or_else(|| vanished("the window has no panes"))?;
chosen.select().await.map_err(|e| tmux_error(&e))?;
chosen
}
other => {
let mut boundary = EffectBoundary::new("select_pane");
let flag = match other {
None | Some("next" | "previous") => None,
Some("up") => Some("-U"),
Some("down") => Some("-D"),
Some("left") => Some("-L"),
Some("right") => Some("-R"),
Some("last") => Some("-l"),
Some(unknown) => {
return Err(bad_input(format!(
"direction must be up, down, left, right, last, next, or \
previous, not {unknown}"
)));
}
};
let mut command = Command::new("select-pane")
.arg("-t")
.arg(target.id().to_string());
if let Some(flag) = flag {
command = command.arg(flag);
}
let result = boundary.tmux(self.server.cmd(command).await)?;
if let Some(error) = result.refusal_for("select-pane") {
return Err(boundary.error(error));
}
boundary.mark();
let window = boundary
.tmux(self.server.window_by_id(target.window_id()).await)?
.ok_or_else(|| boundary.local("the pane's window is gone"))?;
boundary
.tmux(window.active_pane().await)?
.ok_or_else(|| boundary.local("the window reported no active pane"))?
}
};
let socket = self.socket();
Ok(Json(self.pane_view(&selected, socket)))
}
#[tool(
description = "Select a window, making it its session's active window. Give a \
direction to move relative to it instead: next and previous step \
through the session in index order, and last returns to the \
previously active window.",
title = "Select Window",
meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, {
"window" => [TmuxLookup],
"direction" => [TmuxState]
})
)]
pub async fn select_window(
&self,
Parameters(SelectWindowArgs { window, direction }): Parameters<SelectWindowArgs>,
) -> Result<Json<Windows>, ToolError> {
let mut target = self.find_window(&window).await?;
let mut boundary = EffectBoundary::new("select_window");
match direction.as_deref() {
None => {
boundary.tmux(target.select().await)?;
boundary.mark();
}
Some(step) => {
let flag = match step {
"next" => "-n",
"previous" => "-p",
"last" => "-l",
unknown => {
return Err(bad_input(format!(
"direction must be next, previous, or last, not {unknown}"
)));
}
};
if step != "last" {
boundary.tmux(target.select().await)?;
boundary.mark();
}
let result = boundary.tmux(
self.server
.cmd(
Command::new("select-window")
.arg(flag)
.arg("-t")
.arg(target.session_id().to_string()),
)
.await,
)?;
if let Some(error) = result.refusal_for("select-window") {
return Err(boundary.error(error));
}
boundary.mark();
}
}
let session = boundary
.tmux(self.server.session_by_id(target.session_id()).await)?
.ok_or_else(|| boundary.local("the window's session is gone"))?;
let active = boundary
.tmux(session.active_window().await)?
.ok_or_else(|| boundary.local("the session reported no active window"))?;
Ok(Json(Self::render_windows(&[active])))
}
#[tool(
description = "Rearrange a window's panes using a saved tmux layout or a named \
layout and its unique abbreviation. Names follow the running \
daemon's version; mirrored main layouts require tmux 3.5. \
Invalid syntax is refused before window lookup. Return the \
saved layout tmux actually applied.",
title = "Arrange Window Panes",
meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, {
"window" => [TmuxLookup],
"layout" => [TmuxState]
}; idempotent)
)]
pub async fn select_layout(
&self,
Parameters(SelectLayoutArgs { window, layout }): Parameters<SelectLayoutArgs>,
) -> Result<Json<Layout>, ToolError> {
self.server
.validate_layouts([(OsStr::new(&layout), 1)])
.await
.map_err(|e| tmux_error(&e))?;
let mut target = self.find_window(&window).await?;
target
.select_layout(layout)
.await
.map_err(|e| tmux_error(&e))?;
Ok(Json(Layout {
window: target.id().to_string(),
layout: lossy(target.layout()),
}))
}
#[tool(
name = "clear_pane_scrollback",
description = "Discard a pane's scrollback, so the next capture_pane returns only \
what happens next. Use this before running something whose output you \
want to read cleanly: it is far cheaper than reading past the old \
output every time. The visible screen is left alone.",
title = "Clear Pane History",
meta = crate::capability_meta!(Teardown, None, [Delete], [TmuxMetadata], true, true, {
"pane" => [TmuxLookup]
}; idempotent)
)]
pub async fn clear_pane(
&self,
Parameters(PaneArgs { pane }): Parameters<PaneArgs>,
) -> Result<Json<PaneChanged>, ToolError> {
let target = self.find_pane(&pane).await?;
target.clear_history().await.map_err(|e| tmux_error(&e))?;
Ok(Json(PaneChanged {
pane: target.id().to_string(),
}))
}
#[tool(
description = "Put text into a pane through a tmux paste buffer instead of typing it \
key by key. Use this for anything long or awkward: send_keys types the \
text, so a shell reading it can react to each character, and a \
bracketed-paste aware program treats a paste as one block. Optional \
Enter is appended to that same block. Empty text without Enter is a \
guarded buffer-free no-op. Paste targets only the named pane, even when \
synchronized input is enabled. A dead, input-disabled, mode-owned, \
terminal-attended, or inherited-caller target is refused before setup \
and again immediately before paste. The private buffer is deleted after \
setup, refusal, and paste outcomes; observations can still race with tmux. \
A submitted paste is remembered briefly so wait_for_text can discount its \
own echo.",
title = "Paste Text Into Pane",
meta = crate::capability_meta!(Execute, PaneInput, [Change], [TmuxMetadata], true, true, {
"pane" => [TmuxLookup],
"text" => [PaneInput],
"enter" => [PaneInput]
})
)]
pub async fn paste_text(
&self,
Parameters(PasteTextArgs { pane, text, enter }): Parameters<PasteTextArgs>,
) -> Result<Json<Pasted>, ToolError> {
let initial = self
.preflight_pane_input(
&pane,
PaneInputReach::TargetOnly,
MissingSource::CallerInput,
)
.await?;
let bytes = text.len();
if text.is_empty() && !enter {
return Ok(Json(Pasted {
pane: initial.target.id().to_string(),
bytes,
}));
}
let echoed_text = text.clone();
let mut payload = text;
if enter {
payload.push('\n');
}
let buffer = format!(
"tmux-mcp-{}-{}",
std::process::id(),
PASTE_BUFFER_COUNTER.fetch_add(1, Ordering::Relaxed)
);
if let Err(error) = self
.server
.set_buffer(Some(&buffer), OsString::from(payload))
.await
{
let primary = tmux_error(&error);
return Err(cleanup_after_refusal(
primary,
delete_private_paste_buffer(&self.server, &buffer).await,
));
}
let Some(reservation) = initial.reserve() else {
return Err(cleanup_after_refusal(
active_run_error(initial.target.id().as_ref()),
delete_private_paste_buffer(&self.server, &buffer).await,
));
};
let plan = match self
.preflight_reserved_pane_input(
&pane,
PaneInputReach::TargetOnly,
MissingSource::PasteTransition,
&reservation,
)
.await
{
Ok(plan) if initial.same_authority(&plan) && plan.owns(&reservation) => plan,
Ok(_) => {
return Err(cleanup_after_refusal(
bad_input(format!(
"pane {pane} changed its configured input authority before paste dispatch"
)),
delete_private_paste_buffer(&self.server, &buffer).await,
));
}
Err(primary) => {
return Err(cleanup_after_refusal(
primary,
delete_private_paste_buffer(&self.server, &buffer).await,
));
}
};
let target_id = plan.target.id().to_string();
let mut tracked_keys = Vec::new();
if enter {
tracked_keys.push("Enter".to_owned());
}
let echo_text = (!echoed_text.is_empty()).then_some(echoed_text.as_str());
let echo_update = self.echoes.apply(
plan.generation,
&plan.endpoint,
std::slice::from_ref(&target_id),
echo_text,
&tracked_keys,
);
let pasted = plan.target.paste_buffer(Some(&buffer)).await;
if pasted.is_ok() {
self.echoes.commit(echo_update);
} else {
self.echoes.abandon(echo_update);
}
let deleted = delete_private_paste_buffer(&self.server, &buffer).await;
paste_outcome(pasted, deleted).map_err(|error| tmux_error(&error))?;
Ok(Json(Pasted {
pane: plan.target.id().to_string(),
bytes,
}))
}
#[tool(
description = "Signal a tmux wait-for channel, releasing every current waiter. With \
no waiter, one signal is latched; signalling the same channel again \
clears that latch.",
title = "Signal Channel",
meta = crate::capability_meta!(Manage, None, [Change], [TmuxMetadata], true, true, {
"channel" => [TmuxState],
"seconds" => [None]
})
)]
pub async fn signal_channel(
&self,
Parameters(ChannelArgs { channel, .. }): Parameters<ChannelArgs>,
) -> Result<Json<ChannelSignal>, ToolError> {
self.server
.signal_channel(&channel)
.await
.map_err(|e| tmux_error(&e))?;
Ok(Json(ChannelSignal { channel }))
}
}
fn paste_outcome(pasted: Result<(), Error>, deleted: Result<(), Error>) -> Result<(), Error> {
match deleted {
Ok(()) => pasted,
Err(cleanup) => {
let error = match pasted {
Ok(()) => cleanup,
Err(paste) => paste,
};
Err(error.after_effect("paste_text"))
}
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use libtmux::test::TestServer;
use libtmux::{Command, Error, ErrorKind, Server, ServerGoneKind};
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::ErrorCode;
use crate::{SendKeysArgs, TmuxTools};
use super::paste_outcome;
fn cleanup_error() -> Error {
Error::ServerGone {
command: "delete-buffer",
kind: ServerGoneKind::NotRunning,
}
}
#[test]
fn paste_cleanup_decides_whether_replay_is_safe() {
assert!(paste_outcome(Ok(()), Ok(())).is_ok());
let cleanup = paste_outcome(Ok(()), Err(cleanup_error()))
.expect_err("a leaked buffer follows a completed paste");
assert_eq!(cleanup.kind(), ErrorKind::PartialEffect);
let paste = paste_outcome(Err(Error::RuntimeNested), Ok(()))
.expect_err("successful cleanup restores the paste error");
assert_eq!(paste.kind(), ErrorKind::InvalidInput);
let both = paste_outcome(Err(Error::RuntimeNested), Err(cleanup_error()))
.expect_err("failed cleanup leaves the setup effect behind");
assert!(
matches!(
both,
Error::AfterEffect { source, .. }
if source.kind() == ErrorKind::InvalidInput
),
"the paste failure remains the source",
);
}
#[tokio::test]
async fn a_later_send_keys_failure_reports_the_first_effect() {
let guard = TestServer::builder().start().await.expect("tmux starts");
let session = guard
.server()
.new_session("send-boundary")
.await
.expect("a session starts");
session
.set_hook(
"after-send-keys",
"if-shell -F '#{?hook_flag_l,0,1}' \
'wait-for -S retry-send-held; wait-for retry-send-release'",
)
.await
.expect("the Enter reply is held");
let pane = session
.panes()
.await
.expect("panes are listed")
.into_iter()
.next()
.expect("the session has a pane");
let bounded = Server::builder()
.socket_path(guard.socket_path())
.config_file(guard.server().config_file().expect("the fixture config"))
.tmux_executable(guard.server().tmux_executable())
.default_timeout(Duration::from_secs(2))
.build()
.expect("a bounded handle");
let tools = TmuxTools::builder(bounded.clone()).caller(None).build();
let result = tools
.send_keys(Parameters(SendKeysArgs {
pane: pane.id().to_string(),
text: Some(String::from("printf retry-boundary")),
keys: None,
enter: true,
}))
.await;
let held = guard
.server()
.wait_for_channel("retry-send-held", Duration::from_secs(2))
.await
.expect("the hook channel is readable");
guard
.server()
.cmd(Command::new("wait-for").arg("-S").arg("retry-send-release"))
.await
.expect("the hook is released");
drop(tools);
bounded.shutdown().await.expect("the bounded handle stops");
guard.shutdown().await.expect("tmux fixture shuts down");
assert_eq!(held, libtmux::ChannelWait::Signalled);
let Err(error) = result else {
panic!("Enter reached tmux but its held reply did not fail");
};
let error = error.into_error_data();
assert_eq!(error.code, ErrorCode::INTERNAL_ERROR);
let detail = error.data.expect("the error carries detail");
assert_eq!(detail["kind"], "partial_effect", "{detail}");
assert_eq!(detail["retryable"], false, "{detail}");
assert_eq!(detail["stale"], false, "{detail}");
}
}