use super::{INTERACTION_RESPONSE_PLUGIN_ID, RECOVERY_RESUME_TOOL_ID};
use crate::outbox::InteractionOutbox;
use crate::{AGENT_RECOVERY_INTERACTION_ACTION, AGENT_RECOVERY_INTERACTION_PREFIX};
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashMap;
use tirea_contract::event::interaction::{
FrontendToolInvocation, InvocationOrigin, ResponseRouting,
};
use tirea_contract::plugin::phase::{
BeforeToolExecuteContext, PluginPhaseContext, RunStartContext,
};
use tirea_contract::plugin::AgentPlugin;
use tirea_contract::runtime::control::{InferenceError, LoopControlState};
use tirea_contract::{Interaction, InteractionResponse};
use tirea_extension_permission::PermissionState;
use tirea_state::{State, TireaError};
pub(crate) struct InteractionResponsePlugin {
responses: HashMap<String, serde_json::Value>,
}
impl InteractionResponsePlugin {
pub(crate) fn new(approved_ids: Vec<String>, denied_ids: Vec<String>) -> Self {
let mut responses = HashMap::new();
for id in approved_ids {
responses.insert(id, serde_json::Value::Bool(true));
}
for id in denied_ids {
responses.insert(id, serde_json::Value::Bool(false));
}
Self { responses }
}
pub(crate) fn from_responses(responses: Vec<InteractionResponse>) -> Self {
Self {
responses: responses
.into_iter()
.map(|r| (r.interaction_id, r.result))
.collect(),
}
}
pub(crate) fn result_for(&self, interaction_id: &str) -> Option<&serde_json::Value> {
self.responses.get(interaction_id)
}
pub(crate) fn responses(&self) -> Vec<InteractionResponse> {
self.responses
.iter()
.map(|(interaction_id, result)| {
InteractionResponse::new(interaction_id.clone(), result.clone())
})
.collect()
}
pub(crate) fn is_approved(&self, interaction_id: &str) -> bool {
self.result_for(interaction_id)
.map(InteractionResponse::is_approved)
.unwrap_or(false)
}
pub(crate) fn is_denied(&self, interaction_id: &str) -> bool {
self.result_for(interaction_id)
.map(InteractionResponse::is_denied)
.unwrap_or(false)
}
pub(crate) fn has_responses(&self) -> bool {
!self.responses.is_empty()
}
fn pending_interaction_from_step_thread(step: &impl PluginPhaseContext) -> Option<Interaction> {
let state = step.snapshot();
state
.get(LoopControlState::PATH)
.and_then(|agent| agent.get("pending_interaction"))
.cloned()
.and_then(|v| serde_json::from_value::<Interaction>(v).ok())
}
fn persisted_pending_interaction(step: &impl PluginPhaseContext) -> Option<Interaction> {
Self::pending_interaction_from_step_thread(step).or_else(|| {
let agent = step.state_of::<LoopControlState>();
agent.pending_interaction().ok().flatten()
})
}
fn persisted_frontend_invocation(
step: &impl PluginPhaseContext,
) -> Option<FrontendToolInvocation> {
let state = step.snapshot();
state
.get(LoopControlState::PATH)
.and_then(|lc| lc.get("pending_frontend_invocation"))
.cloned()
.and_then(|v| serde_json::from_value::<FrontendToolInvocation>(v).ok())
}
fn push_resolution(
step: &impl PluginPhaseContext,
interaction_id: String,
result: serde_json::Value,
) -> Result<(), String> {
let outbox = step.state_of::<InteractionOutbox>();
outbox
.interaction_resolutions_push(InteractionResponse::new(interaction_id, result))
.map_err(|e| format!("failed to persist interaction resolution: {e}"))
}
fn queue_replay_call(
step: &impl PluginPhaseContext,
call: tirea_contract::thread::ToolCall,
) -> Result<(), String> {
let outbox = step.state_of::<InteractionOutbox>();
outbox
.replay_tool_calls_push(call)
.map_err(|e| format!("failed to persist replay tool call: {e}"))
}
fn clear_pending_interaction_state(step: &impl PluginPhaseContext) -> Result<(), String> {
let state = step.state_of::<LoopControlState>();
if let Err(err) = state.pending_interaction_none() {
if !matches!(err, TireaError::PathNotFound { .. }) {
return Err(format!(
"failed to clear loop_control.pending_interaction: {err}"
));
}
}
if let Err(err) = state.pending_frontend_invocation_none() {
if !matches!(err, TireaError::PathNotFound { .. }) {
return Err(format!(
"failed to clear loop_control.pending_frontend_invocation: {err}"
));
}
}
Ok(())
}
fn report_run_start_error(step: &impl PluginPhaseContext, message: impl Into<String>) {
let message = message.into();
tracing::error!(
plugin = INTERACTION_RESPONSE_PLUGIN_ID,
error = %message,
"interaction response run_start handling failed"
);
if let Err(err) = Self::clear_pending_interaction_state(step) {
tracing::error!(
plugin = INTERACTION_RESPONSE_PLUGIN_ID,
error = %err,
"failed to clear pending interaction state after run_start error"
);
}
let state = step.state_of::<LoopControlState>();
if let Err(err) = state.set_inference_error(Some(InferenceError {
error_type: "interaction_response_error".to_string(),
message,
})) {
tracing::error!(
plugin = INTERACTION_RESPONSE_PLUGIN_ID,
error = %err,
"failed to persist interaction response error"
);
}
}
fn on_run_start(&self, step: &mut RunStartContext<'_, '_>) {
let Some(pending) = Self::persisted_pending_interaction(step) else {
return;
};
if pending.action == AGENT_RECOVERY_INTERACTION_ACTION {
let pending_id = pending.id.as_str();
if self.is_denied(pending_id) {
if let Err(err) = Self::clear_pending_interaction_state(step) {
Self::report_run_start_error(step, err);
return;
}
if let Err(err) = Self::push_resolution(
step,
pending.id.clone(),
self.result_for(pending_id)
.cloned()
.unwrap_or(serde_json::Value::Bool(false)),
) {
Self::report_run_start_error(step, err);
}
return;
}
if !self.is_approved(pending_id) {
return;
}
if let Err(err) = Self::push_resolution(
step,
pending.id.clone(),
self.result_for(pending_id)
.cloned()
.unwrap_or(serde_json::Value::Bool(true)),
) {
Self::report_run_start_error(step, err);
return;
}
let run_id = pending
.parameters
.get("run_id")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| {
pending
.id
.strip_prefix(AGENT_RECOVERY_INTERACTION_PREFIX)
.map(str::to_string)
});
let Some(run_id) = run_id else {
Self::report_run_start_error(
step,
"missing run_id in recovery interaction payload",
);
return;
};
let replay_call = tirea_contract::thread::ToolCall::new(
format!("recovery_resume_{run_id}"),
RECOVERY_RESUME_TOOL_ID,
json!({
"run_id": run_id,
"background": false
}),
);
if let Err(err) = Self::queue_replay_call(step, replay_call) {
Self::report_run_start_error(step, err);
}
return;
}
let Some(invocation) = Self::persisted_frontend_invocation(step) else {
return;
};
let pending_id_owned = invocation.call_id.clone();
let pending_id = pending_id_owned.as_str();
if self.is_denied(pending_id) {
if let Err(err) = Self::clear_pending_interaction_state(step) {
Self::report_run_start_error(step, err);
return;
}
if let Err(err) = Self::push_resolution(
step,
pending_id_owned.clone(),
self.result_for(pending_id)
.cloned()
.unwrap_or(serde_json::Value::Bool(false)),
) {
Self::report_run_start_error(step, err);
}
return;
}
let result_payload = self.result_for(pending_id).cloned();
let is_approved = self.is_approved(pending_id);
let should_continue_use_as_result = result_payload.is_some()
&& matches!(
&invocation.routing,
ResponseRouting::UseAsToolResult | ResponseRouting::PassToLLM
);
if !is_approved && !should_continue_use_as_result {
return;
}
if let Err(err) = Self::push_resolution(
step,
pending_id_owned.clone(),
result_payload
.clone()
.unwrap_or(serde_json::Value::Bool(true)),
) {
Self::report_run_start_error(step, err);
return;
}
if let Err(err) = self.route_frontend_invocation(step, &invocation, result_payload.as_ref())
{
Self::report_run_start_error(step, err);
}
}
fn route_frontend_invocation(
&self,
step: &impl PluginPhaseContext,
inv: &FrontendToolInvocation,
response: Option<&serde_json::Value>,
) -> Result<(), String> {
match &inv.routing {
ResponseRouting::ReplayOriginalTool => {
match &inv.origin {
InvocationOrigin::ToolCallIntercepted {
backend_call_id,
backend_tool_name,
backend_arguments,
} => {
let replay_call = tirea_contract::thread::ToolCall::new(
backend_call_id.clone(),
backend_tool_name.clone(),
backend_arguments.clone(),
);
let permission = step.state_of::<PermissionState>();
let mut approved = permission.approved_calls().ok().unwrap_or_default();
approved.insert(backend_call_id.clone(), true);
permission
.set_approved_calls(approved)
.map_err(|e| format!("failed to persist one-shot approval: {e}"))?;
Self::queue_replay_call(step, replay_call)?;
}
InvocationOrigin::PluginInitiated { .. } => {
let replay_call = tirea_contract::thread::ToolCall::new(
inv.call_id.clone(),
inv.tool_name.clone(),
inv.arguments.clone(),
);
Self::queue_replay_call(step, replay_call)?;
}
}
}
ResponseRouting::UseAsToolResult => {
let replay_call = tirea_contract::thread::ToolCall::new(
inv.call_id.clone(),
inv.tool_name.clone(),
normalize_frontend_tool_result(response, &inv.arguments),
);
Self::queue_replay_call(step, replay_call)?;
}
ResponseRouting::PassToLLM => {
let replay_call = tirea_contract::thread::ToolCall::new(
inv.call_id.clone(),
inv.tool_name.clone(),
normalize_frontend_tool_result(response, &inv.arguments),
);
Self::queue_replay_call(step, replay_call)?;
}
}
Ok(())
}
}
fn normalize_frontend_tool_result(
response: Option<&serde_json::Value>,
fallback_arguments: &serde_json::Value,
) -> serde_json::Value {
match response {
Some(serde_json::Value::Bool(_)) | None => fallback_arguments.clone(),
Some(value) => value.clone(),
}
}
#[async_trait]
impl AgentPlugin for InteractionResponsePlugin {
fn id(&self) -> &str {
INTERACTION_RESPONSE_PLUGIN_ID
}
async fn run_start(&self, ctx: &mut RunStartContext<'_, '_>) {
self.on_run_start(ctx);
}
async fn before_tool_execute(&self, step: &mut BeforeToolExecuteContext<'_, '_>) {
let Some(interaction_id) = step.tool_call_id().map(str::to_string) else {
return;
};
let frontend_call_id = Self::persisted_frontend_invocation(step).map(|inv| inv.call_id);
let effective_id = if let Some(ref fc_id) = frontend_call_id {
if self.is_approved(fc_id) || self.is_denied(fc_id) {
fc_id.clone()
} else {
interaction_id.clone()
}
} else {
interaction_id.clone()
};
let is_approved = self.is_approved(&effective_id);
let is_denied = self.is_denied(&effective_id);
if !is_approved && !is_denied {
return;
}
let persisted_id = Self::persisted_pending_interaction(step).map(|i| i.id);
let id_matches = persisted_id
.as_deref()
.is_some_and(|id| id == interaction_id || Some(id) == frontend_call_id.as_deref());
if !id_matches {
return;
}
if is_denied {
step.deny("User denied the action".to_string());
if let Err(err) = Self::clear_pending_interaction_state(step) {
step.deny(err);
return;
}
let resolved_id = persisted_id.unwrap_or(effective_id);
if let Err(err) =
Self::push_resolution(step, resolved_id, serde_json::Value::Bool(false))
{
step.deny(err);
}
} else if is_approved {
step.proceed();
if let Err(err) = Self::clear_pending_interaction_state(step) {
step.deny(err);
return;
}
let resolved_id = persisted_id.unwrap_or(effective_id);
if let Err(err) =
Self::push_resolution(step, resolved_id, serde_json::Value::Bool(true))
{
step.deny(err);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use serde_json::json;
use std::sync::Arc;
use tirea_contract::plugin::phase::{
AfterInferenceContext, AfterToolExecuteContext, BeforeInferenceContext,
BeforeToolExecuteContext, Phase, RunEndContext, RunStartContext, StepContext,
StepEndContext, StepStartContext,
};
use tirea_contract::plugin::AgentPlugin;
use tirea_contract::runtime::state_paths::{
INTERACTION_OUTBOX_STATE_PATH, PERMISSIONS_STATE_PATH,
};
use tirea_contract::testing::TestFixture;
use tirea_contract::thread::{Message, ToolCall};
use tirea_state::DocCell;
#[async_trait]
trait AgentPluginTestDispatch {
async fn run_phase(&self, phase: Phase, step: &mut StepContext<'_>);
}
#[async_trait]
impl<T> AgentPluginTestDispatch for T
where
T: AgentPlugin + ?Sized,
{
async fn run_phase(&self, phase: Phase, step: &mut StepContext<'_>) {
match phase {
Phase::RunStart => {
let mut ctx = RunStartContext::new(step);
self.run_start(&mut ctx).await;
}
Phase::StepStart => {
let mut ctx = StepStartContext::new(step);
self.step_start(&mut ctx).await;
}
Phase::BeforeInference => {
let mut ctx = BeforeInferenceContext::new(step);
self.before_inference(&mut ctx).await;
}
Phase::AfterInference => {
let mut ctx = AfterInferenceContext::new(step);
self.after_inference(&mut ctx).await;
}
Phase::BeforeToolExecute => {
let mut ctx = BeforeToolExecuteContext::new(step);
self.before_tool_execute(&mut ctx).await;
}
Phase::AfterToolExecute => {
let mut ctx = AfterToolExecuteContext::new(step);
self.after_tool_execute(&mut ctx).await;
}
Phase::StepEnd => {
let mut ctx = StepEndContext::new(step);
self.step_end(&mut ctx).await;
}
Phase::RunEnd => {
let mut ctx = RunEndContext::new(step);
self.run_end(&mut ctx).await;
}
}
}
}
fn replay_calls_from_state(state: &serde_json::Value) -> Vec<ToolCall> {
state
.get(INTERACTION_OUTBOX_STATE_PATH)
.and_then(|agent| agent.get("replay_tool_calls"))
.cloned()
.and_then(|v| serde_json::from_value::<Vec<ToolCall>>(v).ok())
.unwrap_or_default()
}
fn interaction_resolutions_from_state(state: &serde_json::Value) -> Vec<InteractionResponse> {
state
.get(INTERACTION_OUTBOX_STATE_PATH)
.and_then(|agent| agent.get("interaction_resolutions"))
.cloned()
.and_then(|v| serde_json::from_value::<Vec<InteractionResponse>>(v).ok())
.unwrap_or_default()
}
#[tokio::test]
async fn run_start_replays_tool_matching_pending_interaction() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "fc_ask_1",
"action": "tool:write_file",
"parameters": {
"source": "permission"
}
},
"pending_frontend_invocation": {
"call_id": "fc_ask_1",
"tool_name": "PermissionConfirm",
"arguments": { "tool_name": "write_file", "tool_args": { "path": "b.txt" } },
"origin": {
"type": "tool_call_intercepted",
"backend_call_id": "call_write",
"backend_tool_name": "write_file",
"backend_arguments": { "path": "b.txt" }
},
"routing": {
"strategy": "replay_original_tool"
}
}
}
});
let fixture = TestFixture {
doc: DocCell::new(state),
messages: vec![Arc::new(Message::assistant_with_tool_calls(
"tools",
vec![
ToolCall::new("call_read", "read_file", json!({"path": "a.txt"})),
ToolCall::new("call_write", "write_file", json!({"path": "b.txt"})),
],
))],
..TestFixture::new()
};
let plugin = InteractionResponsePlugin::new(vec!["fc_ask_1".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_calls = replay_calls_from_state(&updated);
assert_eq!(replay_calls.len(), 1);
assert_eq!(replay_calls[0].id, "call_write");
assert_eq!(replay_calls[0].name, "write_file");
let approved = updated
.get(PERMISSIONS_STATE_PATH)
.and_then(|p| p.get("approved_calls"))
.and_then(|m| m.get("call_write"))
.and_then(|v| v.as_bool());
assert!(
approved == Some(true),
"approval for replayed call_write should be persisted"
);
}
#[tokio::test]
async fn run_start_replay_requires_frontend_invocation_channel() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "call_write",
"action": "tool:write_file",
"parameters": {
"source": "permission",
"origin_tool_call": {
"id": "call_write",
"name": "write_file",
"arguments": { "path": "b.txt" }
}
}
}
}
});
let fixture = TestFixture {
doc: DocCell::new(state),
messages: vec![Arc::new(Message::assistant_with_tool_calls(
"tools",
vec![ToolCall::new(
"call_write",
"write_file",
json!({"path": "b.txt"}),
)],
))],
..TestFixture::new()
};
let plugin = InteractionResponsePlugin::new(vec!["call_write".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_after = replay_calls_from_state(&updated);
assert!(
replay_after.is_empty(),
"without pending_frontend_invocation metadata, replay must not happen"
);
}
#[tokio::test]
async fn run_start_frontend_interaction_replay_works_without_prior_channel() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "call_copy_1",
"action": "tool:copyToClipboard"
},
"pending_frontend_invocation": {
"call_id": "call_copy_1",
"tool_name": "copyToClipboard",
"arguments": { "text": "hello" },
"origin": {
"type": "plugin_initiated",
"plugin_id": "agui_frontend_tools"
},
"routing": {
"strategy": "use_as_tool_result"
}
}
}
});
let fixture = TestFixture {
doc: DocCell::new(state),
messages: vec![Arc::new(Message::assistant_with_tool_calls(
"tools",
vec![
ToolCall::new("call_search_1", "search", json!({"query": "x"})),
ToolCall::new("call_copy_1", "copyToClipboard", json!({"text": "hello"})),
],
))],
..TestFixture::new()
};
let plugin = InteractionResponsePlugin::new(vec!["call_copy_1".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_after = replay_calls_from_state(&updated);
assert_eq!(replay_after.len(), 1);
assert_eq!(replay_after[0].id, "call_copy_1");
assert_eq!(replay_after[0].name, "copyToClipboard");
}
#[tokio::test]
async fn run_start_frontend_interaction_replay_without_history_uses_pending_payload() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "call_copy_1",
"action": "tool:copyToClipboard",
"parameters": { "text": "hello" }
},
"pending_frontend_invocation": {
"call_id": "call_copy_1",
"tool_name": "copyToClipboard",
"arguments": { "text": "hello" },
"origin": {
"type": "plugin_initiated",
"plugin_id": "agui_frontend_tools"
},
"routing": {
"strategy": "use_as_tool_result"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin = InteractionResponsePlugin::new(vec!["call_copy_1".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_after = replay_calls_from_state(&updated);
assert_eq!(replay_after.len(), 1);
assert_eq!(replay_after[0].id, "call_copy_1");
assert_eq!(replay_after[0].name, "copyToClipboard");
assert_eq!(replay_after[0].arguments["text"], "hello");
}
#[tokio::test]
async fn run_start_permission_replay_without_history_uses_embedded_tool_call() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "fc_ask_2",
"action": "tool:write_file",
"parameters": {
"source": "permission"
}
},
"pending_frontend_invocation": {
"call_id": "fc_ask_2",
"tool_name": "PermissionConfirm",
"arguments": { "tool_name": "write_file", "tool_args": { "path": "a.txt" } },
"origin": {
"type": "tool_call_intercepted",
"backend_call_id": "call_write",
"backend_tool_name": "write_file",
"backend_arguments": { "path": "a.txt" }
},
"routing": {
"strategy": "replay_original_tool"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin = InteractionResponsePlugin::new(vec!["fc_ask_2".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_after = replay_calls_from_state(&updated);
assert_eq!(replay_after.len(), 1);
assert_eq!(replay_after[0].id, "call_write");
assert_eq!(replay_after[0].name, "write_file");
assert_eq!(replay_after[0].arguments["path"], "a.txt");
}
#[tokio::test]
async fn run_start_permission_replay_prefers_origin_tool_call_mapping() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "fc_ask_3",
"action": "tool:write_file",
"parameters": {
"source": "permission"
}
},
"pending_frontend_invocation": {
"call_id": "fc_ask_3",
"tool_name": "PermissionConfirm",
"arguments": { "tool_name": "write_file", "tool_args": { "path": "b.txt" } },
"origin": {
"type": "tool_call_intercepted",
"backend_call_id": "call_write",
"backend_tool_name": "write_file",
"backend_arguments": { "path": "b.txt" }
},
"routing": {
"strategy": "replay_original_tool"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin = InteractionResponsePlugin::new(vec!["fc_ask_3".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_after = replay_calls_from_state(&updated);
assert_eq!(replay_after.len(), 1);
assert_eq!(replay_after[0].id, "call_write");
assert_eq!(replay_after[0].name, "write_file");
assert_eq!(replay_after[0].arguments["path"], "b.txt");
}
#[tokio::test]
async fn run_start_routes_via_frontend_invocation_replay_original_tool() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "call_write",
"action": "tool:write_file",
"parameters": {}
},
"pending_frontend_invocation": {
"call_id": "fc_ask_1",
"tool_name": "PermissionConfirm",
"arguments": { "tool_name": "write_file", "tool_args": { "path": "a.txt" } },
"origin": {
"type": "tool_call_intercepted",
"backend_call_id": "call_write",
"backend_tool_name": "write_file",
"backend_arguments": { "path": "a.txt" }
},
"routing": {
"strategy": "replay_original_tool"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin = InteractionResponsePlugin::new(vec!["fc_ask_1".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_calls = replay_calls_from_state(&updated);
assert_eq!(replay_calls.len(), 1);
assert_eq!(replay_calls[0].id, "call_write");
assert_eq!(replay_calls[0].name, "write_file");
assert_eq!(replay_calls[0].arguments["path"], "a.txt");
let approved = updated
.get(PERMISSIONS_STATE_PATH)
.and_then(|p| p.get("approved_calls"))
.and_then(|m| m.get("call_write"))
.and_then(|v| v.as_bool());
assert_eq!(approved, Some(true));
}
#[tokio::test]
async fn run_start_routes_via_frontend_invocation_use_as_tool_result() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "call_copy",
"action": "tool:copyToClipboard",
"parameters": { "text": "hello" }
},
"pending_frontend_invocation": {
"call_id": "call_copy",
"tool_name": "copyToClipboard",
"arguments": { "text": "hello" },
"origin": {
"type": "plugin_initiated",
"plugin_id": "agui_frontend_tools"
},
"routing": {
"strategy": "use_as_tool_result"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin = InteractionResponsePlugin::new(vec!["call_copy".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_calls = replay_calls_from_state(&updated);
assert_eq!(replay_calls.len(), 1);
assert_eq!(replay_calls[0].id, "call_copy");
assert_eq!(replay_calls[0].name, "copyToClipboard");
assert_eq!(replay_calls[0].arguments["text"], "hello");
assert!(step.pending_patches.is_empty());
}
#[tokio::test]
async fn run_start_use_as_tool_result_preserves_non_boolean_payload() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "call_copy",
"action": "tool:copyToClipboard",
"parameters": { "text": "hello" }
},
"pending_frontend_invocation": {
"call_id": "call_copy",
"tool_name": "copyToClipboard",
"arguments": { "text": "hello" },
"origin": {
"type": "plugin_initiated",
"plugin_id": "agui_frontend_tools"
},
"routing": {
"strategy": "use_as_tool_result"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin = InteractionResponsePlugin::from_responses(vec![InteractionResponse::new(
"call_copy",
json!({
"ok": true,
"copied": "hello"
}),
)]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_calls = replay_calls_from_state(&updated);
assert_eq!(replay_calls.len(), 1);
assert_eq!(replay_calls[0].id, "call_copy");
assert_eq!(replay_calls[0].name, "copyToClipboard");
assert_eq!(replay_calls[0].arguments["ok"], true);
assert_eq!(replay_calls[0].arguments["copied"], "hello");
let resolutions = interaction_resolutions_from_state(&updated);
assert_eq!(resolutions.len(), 1);
assert_eq!(resolutions[0].interaction_id, "call_copy");
assert_eq!(resolutions[0].result["ok"], true);
assert_eq!(resolutions[0].result["copied"], "hello");
}
#[tokio::test]
async fn run_start_replay_failure_sets_inference_error_and_clears_pending() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "fc_ask_fail",
"action": "tool:write_file",
"parameters": {}
},
"pending_frontend_invocation": {
"call_id": "fc_ask_fail",
"tool_name": "PermissionConfirm",
"arguments": { "tool_name": "write_file", "tool_args": { "path": "a.txt" } },
"origin": {
"type": "tool_call_intercepted",
"backend_call_id": "call_write",
"backend_tool_name": "write_file",
"backend_arguments": { "path": "a.txt" }
},
"routing": {
"strategy": "replay_original_tool"
}
}
},
"interaction_outbox": {
"replay_tool_calls": "invalid_type"
}
});
let fixture = TestFixture::new_with_state(state);
let plugin = InteractionResponsePlugin::new(vec!["fc_ask_fail".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
assert!(
updated["loop_control"]["pending_interaction"].is_null(),
"pending interaction should be cleared on run_start failure"
);
assert!(
updated["loop_control"]["pending_frontend_invocation"].is_null(),
"pending frontend invocation should be cleared on run_start failure"
);
assert_eq!(
updated["loop_control"]["inference_error"]["type"],
"interaction_response_error"
);
assert!(
updated["loop_control"]["inference_error"]["message"]
.as_str()
.unwrap_or_default()
.contains("failed to persist replay tool call"),
"expected queue replay failure message in inference_error"
);
}
#[tokio::test]
async fn run_start_recovery_approval_schedules_agent_run_replay() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "agent_recovery_run-1",
"action": "recover_agent_run",
"parameters": {
"run_id": "run-1"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin =
InteractionResponsePlugin::new(vec!["agent_recovery_run-1".to_string()], vec![]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
let updated = fixture.updated_state();
let replay_calls = replay_calls_from_state(&updated);
assert_eq!(replay_calls.len(), 1);
assert_eq!(replay_calls[0].name, RECOVERY_RESUME_TOOL_ID);
assert_eq!(replay_calls[0].arguments["run_id"], "run-1");
assert_eq!(replay_calls[0].arguments["background"], false);
}
#[tokio::test]
async fn run_start_recovery_denial_clears_pending_interaction() {
let state = json!({
"loop_control": {
"pending_interaction": {
"id": "agent_recovery_run-1",
"action": "recover_agent_run",
"parameters": {
"run_id": "run-1"
}
}
}
});
let fixture = TestFixture::new_with_state(state);
let plugin =
InteractionResponsePlugin::new(vec![], vec!["agent_recovery_run-1".to_string()]);
let mut step = fixture.step(vec![]);
plugin.run_phase(Phase::RunStart, &mut step).await;
assert!(
fixture.has_changes(),
"denied recovery must clear pending interaction state"
);
let updated = fixture.updated_state();
let pending = updated
.get("loop_control")
.and_then(|a| a.get("pending_interaction"));
assert!(pending.is_none() || pending == Some(&serde_json::Value::Null));
}
}