1use async_trait::async_trait;
8use serde_json::Value;
9use std::collections::HashSet;
10use std::sync::Arc;
11use tirea_agentos::runtime::loop_runner::{
12 BaseAgent, ParallelToolExecutor, SequentialToolExecutor,
13};
14use tirea_agentos::runtime::{compose_behaviors, ResolvedRun};
15use tirea_contract::runtime::behavior::ReadOnlyContext;
16use tirea_contract::runtime::phase::{
17 ActionSet, BeforeInferenceAction, BeforeToolExecuteAction, SuspendTicket,
18};
19use tirea_contract::runtime::tool_call::{Tool, ToolDescriptor, ToolError, ToolResult};
20use tirea_contract::runtime::AgentBehavior;
21use tirea_contract::runtime::{PendingToolCall, ToolCallResumeMode};
22use tirea_contract::ToolCallContext;
23
24use tirea_protocol_ag_ui::{build_context_addendum, RunAgentInput};
25
26pub fn apply_agui_extensions(resolved: &mut ResolvedRun, request: &RunAgentInput) {
31 if let Some(model) = request.model.as_ref().filter(|m| !m.trim().is_empty()) {
32 resolved.agent.model = model.clone();
33 }
34 if let Some(system_prompt) = request
35 .system_prompt
36 .as_ref()
37 .filter(|prompt| !prompt.trim().is_empty())
38 {
39 resolved.agent.system_prompt = system_prompt.clone();
40 }
41 if let Some(config) = request.config.clone() {
42 apply_agui_tool_execution_mode_override(resolved, &config);
43 apply_agui_chat_options_overrides(resolved, &config);
44 }
45
46 let frontend_defs = request.frontend_tools();
47 let frontend_tool_names: HashSet<String> =
48 frontend_defs.iter().map(|tool| tool.name.clone()).collect();
49
50 for tool in frontend_defs {
52 let stub = Arc::new(FrontendToolStub::new(
53 tool.name.clone(),
54 tool.description.clone(),
55 tool.parameters.clone(),
56 ));
57 let id = stub.descriptor().id.clone();
58 resolved.tools.entry(id).or_insert(stub as Arc<dyn Tool>);
59 }
60
61 if !frontend_tool_names.is_empty() {
63 add_behavior_mut(
64 &mut resolved.agent,
65 Arc::new(FrontendToolPendingPlugin::new(frontend_tool_names)),
66 );
67 }
68
69 if let Some(addendum) = build_context_addendum(request) {
71 add_behavior_mut(
72 &mut resolved.agent,
73 Arc::new(ContextInjectionPlugin::new(addendum)),
74 );
75 }
76}
77
78fn add_behavior_mut(agent: &mut BaseAgent, behavior: Arc<dyn AgentBehavior>) {
80 if agent.behavior.id() == "noop" {
81 agent.behavior = behavior;
82 } else {
83 let id = format!("{}+{}", agent.behavior.id(), behavior.id());
84 agent.behavior = compose_behaviors(id, vec![agent.behavior.clone(), behavior]);
85 }
86}
87
88fn apply_agui_tool_execution_mode_override(resolved: &mut ResolvedRun, config: &Value) {
89 let mode = config
90 .get("toolExecutionMode")
91 .or_else(|| config.get("tool_execution_mode"))
92 .and_then(Value::as_str)
93 .map(|value| value.trim().to_ascii_lowercase());
94
95 match mode.as_deref() {
96 Some("sequential") => {
97 resolved.agent.tool_executor = Arc::new(SequentialToolExecutor);
98 }
99 Some("parallel_batch_approval") => {
100 resolved.agent.tool_executor = Arc::new(ParallelToolExecutor::batch_approval());
101 }
102 Some("parallel_streaming") => {
103 resolved.agent.tool_executor = Arc::new(ParallelToolExecutor::streaming());
104 }
105 _ => {}
106 }
107}
108
109fn apply_agui_chat_options_overrides(resolved: &mut ResolvedRun, config: &Value) {
110 let mut chat_options = resolved.agent.chat_options.clone().unwrap_or_default();
111 let mut changed = false;
112
113 if let Some(map) = config.as_object() {
114 if let Some(value) = get_bool(map, "captureReasoningContent", "capture_reasoning_content") {
115 chat_options.capture_reasoning_content = Some(value);
116 changed = true;
117 }
118 if let Some(value) = get_bool(
119 map,
120 "normalizeReasoningContent",
121 "normalize_reasoning_content",
122 ) {
123 chat_options.normalize_reasoning_content = Some(value);
124 changed = true;
125 }
126 }
127
128 if let Some(map) = config
129 .get("chatOptions")
130 .and_then(Value::as_object)
131 .or_else(|| config.get("chat_options").and_then(Value::as_object))
132 {
133 if let Some(value) = get_bool(map, "captureReasoningContent", "capture_reasoning_content") {
134 chat_options.capture_reasoning_content = Some(value);
135 changed = true;
136 }
137 if let Some(value) = get_bool(
138 map,
139 "normalizeReasoningContent",
140 "normalize_reasoning_content",
141 ) {
142 chat_options.normalize_reasoning_content = Some(value);
143 changed = true;
144 }
145 }
146
147 if changed {
148 resolved.agent.chat_options = Some(chat_options);
149 }
150}
151
152fn get_bool(map: &serde_json::Map<String, Value>, primary: &str, alias: &str) -> Option<bool> {
153 map.get(primary)
154 .or_else(|| map.get(alias))
155 .and_then(Value::as_bool)
156}
157
158struct FrontendToolStub {
163 descriptor: ToolDescriptor,
164}
165
166impl FrontendToolStub {
167 fn new(name: String, description: String, parameters: Option<Value>) -> Self {
168 let mut descriptor = ToolDescriptor::new(&name, &name, description);
169 if let Some(parameters) = parameters {
170 descriptor = descriptor.with_parameters(parameters);
171 }
172 Self { descriptor }
173 }
174}
175
176#[async_trait]
177impl Tool for FrontendToolStub {
178 fn descriptor(&self) -> ToolDescriptor {
179 self.descriptor.clone()
180 }
181
182 async fn execute(
183 &self,
184 _args: Value,
185 _ctx: &ToolCallContext<'_>,
186 ) -> Result<ToolResult, ToolError> {
187 Ok(ToolResult::error(
188 &self.descriptor.id,
189 "frontend tool stub should be intercepted before backend execution",
190 ))
191 }
192}
193
194struct ContextInjectionPlugin {
197 addendum: String,
198}
199
200impl ContextInjectionPlugin {
201 fn new(addendum: String) -> Self {
202 Self { addendum }
203 }
204}
205
206#[async_trait]
207impl AgentBehavior for ContextInjectionPlugin {
208 fn id(&self) -> &str {
209 "agui_context_injection"
210 }
211
212 async fn before_inference(
213 &self,
214 _ctx: &ReadOnlyContext<'_>,
215 ) -> ActionSet<BeforeInferenceAction> {
216 ActionSet::single(BeforeInferenceAction::AddSystemContext(
217 self.addendum.clone(),
218 ))
219 }
220}
221
222struct FrontendToolPendingPlugin {
224 frontend_tools: HashSet<String>,
225}
226
227impl FrontendToolPendingPlugin {
228 fn new(frontend_tools: HashSet<String>) -> Self {
229 Self { frontend_tools }
230 }
231}
232
233#[async_trait]
234impl AgentBehavior for FrontendToolPendingPlugin {
235 fn id(&self) -> &str {
236 "agui_frontend_tools"
237 }
238
239 async fn before_tool_execute(
240 &self,
241 ctx: &ReadOnlyContext<'_>,
242 ) -> ActionSet<BeforeToolExecuteAction> {
243 let Some(tool_name) = ctx.tool_name() else {
244 return ActionSet::empty();
245 };
246 if !self.frontend_tools.contains(tool_name) {
247 return ActionSet::empty();
248 }
249
250 if let Some(resume) = ctx.resume_input() {
251 let result = match resume.action {
252 tirea_contract::io::ResumeDecisionAction::Resume => {
253 ToolResult::success(tool_name.to_string(), resume.result.clone())
254 }
255 tirea_contract::io::ResumeDecisionAction::Cancel => ToolResult::error(
256 tool_name.to_string(),
257 resume
258 .reason
259 .clone()
260 .filter(|r| !r.trim().is_empty())
261 .unwrap_or_else(|| "User denied the action".to_string()),
262 ),
263 };
264 return ActionSet::single(BeforeToolExecuteAction::SetToolResult(result));
265 }
266 let Some(call_id) = ctx.tool_call_id().map(str::to_string) else {
267 return ActionSet::empty();
268 };
269
270 let args = ctx.tool_args().cloned().unwrap_or_default();
271 let suspension = tirea_contract::Suspension::new(&call_id, format!("tool:{tool_name}"))
272 .with_parameters(args.clone());
273 ActionSet::single(BeforeToolExecuteAction::Suspend(SuspendTicket::new(
274 suspension,
275 PendingToolCall::new(call_id, tool_name.to_string(), args),
276 ToolCallResumeMode::UseDecisionAsToolResult,
277 )))
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284 use async_trait::async_trait;
285 use serde_json::json;
286 use std::collections::HashMap;
287 use std::sync::Arc;
288 use tirea_agentos::runtime::loop_runner::BaseAgent;
289 use tirea_contract::runtime::phase::{Phase, StepContext};
290 use tirea_contract::runtime::tool_call::ToolGate;
291 use tirea_contract::testing::TestFixture;
292 use tirea_contract::thread::ToolCall;
293 use tirea_protocol_ag_ui::{Context, Message, ToolExecutionLocation};
294
295 fn empty_resolved() -> ResolvedRun {
296 ResolvedRun {
297 agent: BaseAgent::default(),
298 tools: HashMap::new(),
299 run_policy: tirea_contract::RunPolicy::new(),
300 parent_tool_call_id: None,
301 }
302 }
303
304 fn build_read_only_ctx<'a>(phase: Phase, step: &'a StepContext<'a>) -> ReadOnlyContext<'a> {
305 let mut ctx = ReadOnlyContext::new(
306 phase,
307 step.thread_id(),
308 step.messages(),
309 step.run_policy(),
310 step.ctx().doc(),
311 );
312 if let Some(gate) = step.gate.as_ref() {
313 ctx = ctx.with_tool_info(&gate.name, &gate.id, Some(&gate.args));
314 if let Some(result) = gate.result.as_ref() {
315 ctx = ctx.with_tool_result(result);
316 }
317 }
318 if let Some(call_id) = step.tool_call_id() {
319 if let Ok(Some(resume)) = step.ctx().resume_input_for(call_id) {
320 ctx = ctx.with_resume_input(resume);
321 }
322 }
323 ctx
324 }
325
326 struct MarkerPlugin;
327
328 #[async_trait]
329 impl AgentBehavior for MarkerPlugin {
330 fn id(&self) -> &str {
331 "marker_plugin"
332 }
333 }
334
335 #[test]
336 fn injects_frontend_tools_into_resolved() {
337 let request = RunAgentInput {
338 thread_id: "t1".to_string(),
339 run_id: "r1".to_string(),
340 messages: vec![Message::user("hello")],
341 tools: vec![
342 tirea_protocol_ag_ui::Tool {
343 name: "copyToClipboard".to_string(),
344 description: "copy".to_string(),
345 parameters: Some(json!({
346 "type": "object",
347 "properties": {
348 "text": { "type": "string" }
349 },
350 "required": ["text"]
351 })),
352 execute: ToolExecutionLocation::Frontend,
353 },
354 tirea_protocol_ag_ui::Tool::backend("search", "backend search"),
355 ],
356 context: vec![],
357 state: None,
358 parent_run_id: None,
359 parent_thread_id: None,
360 model: None,
361 system_prompt: None,
362 config: None,
363 forwarded_props: None,
364 };
365
366 let mut resolved = empty_resolved();
367 apply_agui_extensions(&mut resolved, &request);
368 assert_eq!(resolved.agent.tool_executor.name(), "parallel_streaming");
369 assert!(resolved.tools.contains_key("copyToClipboard"));
370 assert_eq!(resolved.tools.len(), 1);
372 assert!(resolved.agent.behavior.id().contains("agui_frontend_tools"));
374 }
375
376 #[test]
377 fn no_behaviors_added_when_only_decisions_are_present() {
378 let request = RunAgentInput {
379 thread_id: "t1".to_string(),
380 run_id: "r1".to_string(),
381 messages: vec![
382 Message::user("hello"),
383 Message::tool("true", "interaction_1"),
384 ],
385 tools: vec![],
386 context: vec![],
387 state: None,
388 parent_run_id: None,
389 parent_thread_id: None,
390 model: None,
391 system_prompt: None,
392 config: None,
393 forwarded_props: None,
394 };
395
396 let mut resolved = empty_resolved();
397 apply_agui_extensions(&mut resolved, &request);
398 assert!(resolved.tools.is_empty());
399 assert_eq!(resolved.agent.behavior.id(), "noop");
400 }
401
402 #[test]
403 fn no_behaviors_added_for_non_boolean_decision_payload_without_frontend_tools() {
404 let request = RunAgentInput {
405 thread_id: "t1".to_string(),
406 run_id: "r1".to_string(),
407 messages: vec![
408 Message::user("hello"),
409 Message::tool(r#"{"todo":"ship starter"}"#, "call_copy_1"),
410 ],
411 tools: vec![],
412 context: vec![],
413 state: Some(json!({
414 "__suspended_tool_calls": {
415 "calls": {
416 "call_copy_1": {
417 "call_id": "call_copy_1",
418 "tool_name": "copyToClipboard",
419 "suspension": {
420 "id": "call_copy_1",
421 "action": "tool:copyToClipboard"
422 },
423 "arguments": {},
424 "pending": {
425 "id": "call_copy_1",
426 "name": "copyToClipboard",
427 "arguments": {}
428 },
429 "resume_mode": "use_decision_as_tool_result"
430 }
431 }
432 }
433 })),
434 parent_run_id: None,
435 parent_thread_id: None,
436 model: None,
437 system_prompt: None,
438 config: None,
439 forwarded_props: None,
440 };
441
442 let mut resolved = empty_resolved();
443 apply_agui_extensions(&mut resolved, &request);
444 assert!(resolved.tools.is_empty());
445 assert_eq!(resolved.agent.behavior.id(), "noop");
446 }
447
448 #[test]
449 fn injects_frontend_behavior_even_when_decisions_are_present() {
450 let request = RunAgentInput {
451 thread_id: "t1".to_string(),
452 run_id: "r1".to_string(),
453 messages: vec![Message::user("hello"), Message::tool("true", "call_1")],
454 tools: vec![tirea_protocol_ag_ui::Tool {
455 name: "copyToClipboard".to_string(),
456 description: "copy".to_string(),
457 parameters: None,
458 execute: ToolExecutionLocation::Frontend,
459 }],
460 context: vec![],
461 state: None,
462 parent_run_id: None,
463 parent_thread_id: None,
464 model: None,
465 system_prompt: None,
466 config: None,
467 forwarded_props: None,
468 };
469
470 let mut resolved = empty_resolved();
471 apply_agui_extensions(&mut resolved, &request);
472 assert!(resolved.tools.contains_key("copyToClipboard"));
473 assert_eq!(resolved.agent.behavior.id(), "agui_frontend_tools");
475 }
476
477 #[test]
478 fn composes_frontend_pending_behavior_with_existing_behavior() {
479 let request = RunAgentInput {
480 thread_id: "t1".to_string(),
481 run_id: "r1".to_string(),
482 messages: vec![Message::user("hello")],
483 tools: vec![tirea_protocol_ag_ui::Tool {
484 name: "copyToClipboard".to_string(),
485 description: "copy".to_string(),
486 parameters: None,
487 execute: ToolExecutionLocation::Frontend,
488 }],
489 context: vec![],
490 state: None,
491 parent_run_id: None,
492 parent_thread_id: None,
493 model: None,
494 system_prompt: None,
495 config: None,
496 forwarded_props: None,
497 };
498
499 let mut resolved = empty_resolved();
500 add_behavior_mut(&mut resolved.agent, Arc::new(MarkerPlugin));
501
502 apply_agui_extensions(&mut resolved, &request);
503
504 let behavior_id = resolved.agent.behavior.id();
505 assert!(
506 behavior_id.contains("agui_frontend_tools"),
507 "behavior should contain frontend tools, got: {behavior_id}"
508 );
509 assert!(
510 behavior_id.contains("marker_plugin"),
511 "behavior should contain marker plugin, got: {behavior_id}"
512 );
513 }
514
515 #[test]
516 fn no_changes_without_frontend_or_response_data() {
517 let request = RunAgentInput::new("t1", "r1").with_message(Message::user("hello"));
518 let mut resolved = empty_resolved();
519 apply_agui_extensions(&mut resolved, &request);
520 assert_eq!(resolved.agent.tool_executor.name(), "parallel_streaming");
521 assert!(resolved.tools.is_empty());
522 assert_eq!(resolved.agent.behavior.id(), "noop");
523 }
524
525 #[tokio::test]
526 async fn frontend_pending_plugin_marks_frontend_call_as_pending() {
527 let plugin =
528 FrontendToolPendingPlugin::new(["copyToClipboard".to_string()].into_iter().collect());
529 let fixture = TestFixture::new();
530 let mut step = fixture.step(vec![]);
531 let call = ToolCall::new("call_1", "copyToClipboard", json!({"text":"hello"}));
532 step.gate = Some(ToolGate::from_tool_call(&call));
533
534 let ctx = build_read_only_ctx(Phase::BeforeToolExecute, &step);
535 let actions = plugin.before_tool_execute(&ctx).await;
536 tirea_contract::testing::apply_before_tool_for_test(&mut step, actions);
537
538 let gate = step.gate.as_ref().expect("ToolGate should exist");
539 assert!(gate.pending, "should be pending (suspended)");
540 let ticket = gate
541 .suspend_ticket
542 .as_ref()
543 .expect("should have SuspendTool ticket");
544 assert_eq!(ticket.suspension.action, "tool:copyToClipboard");
545 assert_eq!(ticket.pending.id, "call_1");
546 assert_eq!(ticket.pending.name, "copyToClipboard");
547 assert_eq!(ticket.pending.arguments["text"], "hello");
548 assert_eq!(
549 ticket.resume_mode,
550 tirea_contract::runtime::ToolCallResumeMode::UseDecisionAsToolResult
551 );
552 }
553
554 #[tokio::test]
555 async fn frontend_pending_plugin_resume_sets_tool_result() {
556 let plugin =
557 FrontendToolPendingPlugin::new(["copyToClipboard".to_string()].into_iter().collect());
558 let fixture = TestFixture::new_with_state(json!({
559 "__tool_call_scope": {
560 "call_1": {
561 "tool_call_state": {
562 "call_id": "call_1",
563 "tool_name": "copyToClipboard",
564 "arguments": { "text": "hello" },
565 "status": "resuming",
566 "resume_token": "call_1",
567 "resume": {
568 "decision_id": "d1",
569 "action": "resume",
570 "result": { "accepted": true },
571 "updated_at": 1
572 },
573 "scratch": null,
574 "updated_at": 1
575 }
576 }
577 }
578 }));
579 let mut step = fixture.step(vec![]);
580 let call = ToolCall::new("call_1", "copyToClipboard", json!({"text":"hello"}));
581 step.gate = Some(ToolGate::from_tool_call(&call));
582
583 let ctx = build_read_only_ctx(Phase::BeforeToolExecute, &step);
584 let actions = plugin.before_tool_execute(&ctx).await;
585 tirea_contract::testing::apply_before_tool_for_test(&mut step, actions);
586
587 let gate = step.gate.as_ref().expect("ToolGate should exist");
588 assert!(!gate.blocked, "should not be blocked");
589 assert!(!gate.pending, "should not be pending");
590 let result = gate
591 .result
592 .as_ref()
593 .expect("resume should produce OverrideToolResult");
594 assert_eq!(result.tool_name, "copyToClipboard");
595 assert_eq!(
596 result.status,
597 tirea_contract::runtime::tool_call::ToolStatus::Success
598 );
599 assert_eq!(result.data, json!({"accepted": true}));
600 }
601
602 #[tokio::test]
603 async fn frontend_pending_plugin_cancel_sets_error_result() {
604 let plugin =
605 FrontendToolPendingPlugin::new(["copyToClipboard".to_string()].into_iter().collect());
606 let fixture = TestFixture::new_with_state(json!({
607 "__tool_call_scope": {
608 "call_1": {
609 "tool_call_state": {
610 "call_id": "call_1",
611 "tool_name": "copyToClipboard",
612 "arguments": { "text": "hello" },
613 "status": "resuming",
614 "resume_token": "call_1",
615 "resume": {
616 "decision_id": "d1",
617 "action": "cancel",
618 "result": null,
619 "reason": "user denied",
620 "updated_at": 1
621 },
622 "scratch": null,
623 "updated_at": 1
624 }
625 }
626 }
627 }));
628 let mut step = fixture.step(vec![]);
629 let call = ToolCall::new("call_1", "copyToClipboard", json!({"text":"hello"}));
630 step.gate = Some(ToolGate::from_tool_call(&call));
631
632 let ctx = build_read_only_ctx(Phase::BeforeToolExecute, &step);
633 let actions = plugin.before_tool_execute(&ctx).await;
634 tirea_contract::testing::apply_before_tool_for_test(&mut step, actions);
635
636 let gate = step.gate.as_ref().expect("ToolGate should exist");
637 assert!(!gate.blocked, "should not be blocked");
638 assert!(!gate.pending, "should not be pending");
639 let result = gate
640 .result
641 .as_ref()
642 .expect("cancel should produce OverrideToolResult");
643 assert_eq!(
644 result.status,
645 tirea_contract::runtime::tool_call::ToolStatus::Error
646 );
647 assert_eq!(result.message.as_deref(), Some("user denied"));
648 }
649
650 #[test]
651 fn injects_context_injection_behavior_when_context_present() {
652 let request = RunAgentInput {
653 thread_id: "t1".to_string(),
654 run_id: "r1".to_string(),
655 messages: vec![Message::user("hello")],
656 tools: vec![],
657 context: vec![Context {
658 description: "Current tasks".to_string(),
659 value: json!(["Review PR", "Write tests"]),
660 }],
661 state: None,
662 parent_run_id: None,
663 parent_thread_id: None,
664 model: None,
665 system_prompt: None,
666 config: None,
667 forwarded_props: None,
668 };
669
670 let mut resolved = empty_resolved();
671 apply_agui_extensions(&mut resolved, &request);
672 assert!(resolved
673 .agent
674 .behavior
675 .id()
676 .contains("agui_context_injection"));
677 }
678
679 #[tokio::test]
680 async fn context_injection_behavior_adds_system_context() {
681 let request = RunAgentInput {
682 thread_id: "t1".to_string(),
683 run_id: "r1".to_string(),
684 messages: vec![Message::user("hello")],
685 tools: vec![],
686 context: vec![Context {
687 description: "Task list".to_string(),
688 value: json!(["Review PR", "Write tests"]),
689 }],
690 state: None,
691 parent_run_id: None,
692 parent_thread_id: None,
693 model: None,
694 system_prompt: None,
695 config: None,
696 forwarded_props: None,
697 };
698
699 let mut resolved = empty_resolved();
700 apply_agui_extensions(&mut resolved, &request);
701 let behavior = &resolved.agent.behavior;
702
703 let fixture = TestFixture::new();
704 let mut step = fixture.step(vec![]);
705 let ctx = build_read_only_ctx(Phase::BeforeInference, &step);
706
707 let actions = behavior.before_inference(&ctx).await;
708 tirea_contract::testing::apply_before_inference_for_test(&mut step, actions);
709
710 assert!(!step.inference.system_context.is_empty());
711 let merged = step.inference.system_context.join("\n");
712 assert!(
713 merged.contains("Task list"),
714 "should contain context description"
715 );
716 assert!(
717 merged.contains("Review PR"),
718 "should contain context values"
719 );
720 }
721
722 #[test]
723 fn no_context_injection_behavior_when_context_empty() {
724 let request = RunAgentInput::new("t1", "r1").with_message(Message::user("hello"));
725 let mut resolved = empty_resolved();
726 apply_agui_extensions(&mut resolved, &request);
727 assert!(!resolved
728 .agent
729 .behavior
730 .id()
731 .contains("agui_context_injection"));
732 }
733
734 #[test]
735 fn applies_request_model_and_system_prompt_overrides() {
736 let request = RunAgentInput::new("t1", "r1")
737 .with_message(Message::user("hello"))
738 .with_model("gpt-4.1")
739 .with_system_prompt("You are precise.");
740
741 let mut resolved = empty_resolved();
742 resolved.agent.model = "base-model".to_string();
743 resolved.agent.system_prompt = "base-prompt".to_string();
744
745 apply_agui_extensions(&mut resolved, &request);
746
747 assert_eq!(resolved.agent.model, "gpt-4.1");
748 assert_eq!(resolved.agent.system_prompt, "You are precise.");
749 }
750
751 #[test]
752 fn ignores_non_runtime_agui_config_fields() {
753 let request = RunAgentInput::new("t1", "r1")
754 .with_message(Message::user("hello"))
755 .with_state(json!({"k":"v"}))
756 .with_forwarded_props(json!({"session":"abc"}));
757 let mut request = request;
758 request.config = Some(json!({"temperature": 0.2}));
759
760 let mut resolved = empty_resolved();
761 apply_agui_extensions(&mut resolved, &request);
762
763 let options = resolved
764 .agent
765 .chat_options
766 .as_ref()
767 .expect("default chat options should be preserved");
768 assert_eq!(options.capture_usage, Some(true));
769 assert_eq!(options.capture_reasoning_content, Some(true));
770 }
771
772 #[test]
773 fn applies_chat_options_overrides_from_agui_config() {
774 let mut request = RunAgentInput::new("t1", "r1").with_message(Message::user("hello"));
775 request.config = Some(json!({
776 "captureReasoningContent": true,
777 "normalizeReasoningContent": true,
778 "reasoningEffort": "high"
779 }));
780
781 let mut resolved = empty_resolved();
782 apply_agui_extensions(&mut resolved, &request);
783
784 let options = resolved
785 .agent
786 .chat_options
787 .expect("chat options should exist");
788 assert_eq!(options.capture_reasoning_content, Some(true));
789 assert_eq!(options.normalize_reasoning_content, Some(true));
790 }
791
792 #[test]
793 fn applies_tool_execution_mode_override_from_agui_config() {
794 let mut request = RunAgentInput::new("t1", "r1").with_message(Message::user("hello"));
795 request.config = Some(json!({
796 "toolExecutionMode": "parallel_batch_approval"
797 }));
798
799 let mut resolved = empty_resolved();
800 apply_agui_extensions(&mut resolved, &request);
801
802 assert_eq!(
803 resolved.agent.tool_executor.name(),
804 "parallel_batch_approval"
805 );
806 }
807
808 #[test]
809 fn applies_nested_chat_options_overrides_from_agui_config() {
810 let mut request = RunAgentInput::new("t1", "r1").with_message(Message::user("hello"));
811 request.config = Some(json!({
812 "chat_options": {
813 "capture_reasoning_content": false,
814 "reasoning_effort": 256
815 }
816 }));
817
818 let mut resolved = empty_resolved();
819 apply_agui_extensions(&mut resolved, &request);
820
821 let options = resolved
822 .agent
823 .chat_options
824 .expect("chat options should exist");
825 assert_eq!(options.capture_reasoning_content, Some(false));
826 assert_eq!(options.normalize_reasoning_content, None);
827 }
828}