1use std::sync::Arc;
8
9use super::actor::{Live, SessionActor};
10use super::types::*;
11
12fn compact_request(cmd: &str, arg: &str) -> Option<crate::engine::commands::CommandResult> {
14 match crate::engine::commands::evaluate_engine_command(cmd, arg) {
15 Some(r @ crate::engine::commands::CommandResult::Compact { .. }) => Some(r),
16 _ => None,
17 }
18}
19
20pub fn engine_command_reply(
24 cmd: &str,
25 arg: &str,
26 runtime: &mut crate::Runtime,
27 session: &mut crate::Session,
28) -> (serde_json::Value, bool) {
29 use crate::engine::commands::{handle_engine_command, CommandResult};
30 match handle_engine_command(cmd, arg, runtime) {
31 None => (serde_json::json!({ "kind": "unhandled" }), false),
32 Some(CommandResult::Quit) => (serde_json::json!({ "kind": "quit" }), false),
33 Some(CommandResult::ModelChanged {
34 model,
35 reasoning_clamped,
36 }) => {
37 session.model = runtime.model().to_string();
38 let mut text = format!("model → {}", model);
39 let mut clamp = serde_json::Value::Null;
40 if let Some(c) = reasoning_clamped {
41 session.thinking_level = runtime.thinking_level().to_string();
42 text.push_str(&format!(
43 "\nthinking → {} (clamped from {}: not supported by {})",
44 c.to.as_str(),
45 c.from.as_str(),
46 runtime.model()
47 ));
48 clamp = serde_json::json!({ "from": c.from.as_str(), "to": c.to.as_str() });
49 }
50 (
51 serde_json::json!({
52 "kind": "notice",
53 "event": "model_changed",
54 "text": text,
55 "model": runtime.model(),
56 "clamp": clamp,
57 }),
58 true,
59 )
60 }
61 Some(CommandResult::ThinkingChanged { spec }) => {
62 session.thinking_level = spec.config_value();
63 (
64 serde_json::json!({
65 "kind": "notice",
66 "event": "thinking_changed",
67 "text": format!("thinking → {}", spec.level()),
68 "level": spec.level().to_string(),
69 "config_value": spec.config_value(),
70 }),
71 true,
72 )
73 }
74 Some(CommandResult::Compact { .. }) => (serde_json::json!({ "kind": "none" }), false),
75 Some(CommandResult::Error(e)) => (serde_json::json!({ "kind": "error", "text": e }), false),
76 Some(CommandResult::Output(text)) => {
77 (serde_json::json!({ "kind": "output", "text": text }), false)
78 }
79 Some(_) => (serde_json::json!({ "kind": "none" }), false),
80 }
81}
82
83impl SessionActor {
84 pub(crate) async fn engine_command(&mut self, id: u64, cmd: String, arg: String) {
90 use crate::engine::commands::CommandResult;
91 if cmd == "saveas" {
94 let value = self.session_name(&arg).await;
95 self.emit(SessionEventWire::QueryResult { id, value });
96 return;
97 }
98 if cmd == "keepwarm" {
101 let value = self.cache_keepwarm_command(&arg).await;
102 self.emit(SessionEventWire::QueryResult { id, value });
103 return;
104 }
105 let (value, view_changed) =
106 engine_command_reply(&cmd, &arg, &mut self.runtime, &mut self.conv.session);
107 if view_changed {
108 self.publish_view().await;
109 }
110 if let Some(CommandResult::Compact {
111 custom_instructions,
112 }) = compact_request(&cmd, &arg)
113 {
114 self.compact(custom_instructions, "manual").await;
115 }
116 self.emit(SessionEventWire::QueryResult { id, value });
117 }
118
119 async fn session_name(&mut self, arg: &str) -> serde_json::Value {
122 let trimmed = arg.trim();
123 self.persister.flush().await;
126 if trimmed.is_empty() {
127 self.conv.session.clear_name();
128 let _ = self.conv.session.save().await;
129 self.sync_name();
130 self.emit_conversation();
131 return serde_json::json!({
132 "kind": "output", "text": "session name cleared", "name": serde_json::Value::Null,
133 });
134 }
135 match self.conv.session.set_name(trimmed) {
136 Ok(()) => {
137 let _ = self.conv.session.save().await;
138 self.sync_name();
139 self.emit_conversation();
140 serde_json::json!({
141 "kind": "output",
142 "text": format!("session named '{}'", trimmed),
143 "name": trimmed,
144 })
145 }
146 Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
147 }
148 }
149
150 pub(crate) async fn submit_prepared(
154 &mut self,
155 messages: Vec<crate::SharedMessage>,
156 user_text: Option<String>,
157 ) {
158 if self.streaming {
159 self.emit(SessionEventWire::SystemNotice(
160 "cannot load a skill while streaming".into(),
161 ));
162 return;
163 }
164 self.conv.api_messages.extend(messages);
165 if let Some(text) = user_text {
166 self.conv
167 .api_messages
168 .push(Arc::new(serde_json::json!({"role": "user", "content": text})));
169 }
170 self.start_turn(TurnTrigger::PluginCommand, None).await;
171 }
172
173 pub(crate) async fn plugin_command(&mut self, id: u64, plugin: String, name: String, arg: String) {
177 let value = match Self::find_plugin_command(&plugin, &name) {
178 None => serde_json::json!({
179 "kind": "error",
180 "text": format!("unknown plugin command /{plugin}:{name}"),
181 }),
182 Some(cmd) => match crate::skills::commands::execute_plugin_command_with_tools(
183 &cmd,
184 &arg,
185 self.runtime.tools_shared(),
186 )
187 .await
188 {
189 Ok(output) => serde_json::json!({
190 "kind": "plugin_output",
191 "status": output.status,
192 "stdout": output.stdout,
193 "stderr": output.stderr,
194 }),
195 Err(e) => serde_json::json!({ "kind": "error", "text": e.to_string() }),
196 },
197 };
198 self.emit(SessionEventWire::QueryResult { id, value });
199 }
200
201 fn find_plugin_command(
202 plugin: &str,
203 name: &str,
204 ) -> Option<Arc<crate::skills::registry::RegisteredPluginCommand>> {
205 let host = crate::host::EngineHost::current()?;
206 match host.command_registry().resolve(&format!("{plugin}:{name}")) {
207 crate::skills::registry::Resolution::PluginCommand(c) => Some(c),
208 _ => None,
209 }
210 }
211
212 pub(crate) async fn resume(&mut self, id: u64, query: String) {
216 if self.streaming {
217 self.emit(SessionEventWire::QueryResult {
218 id,
219 value: serde_json::json!({ "kind": "error", "text": "cannot resume while streaming" }),
220 });
221 return;
222 }
223 self.persister.flush().await;
226 let session = match crate::resolve_session(&query) {
227 Ok(s) => s,
228 Err(e) => {
229 self.emit(SessionEventWire::QueryResult {
230 id,
231 value: serde_json::json!({ "kind": "error", "text": e.to_string() }),
232 });
233 return;
234 }
235 };
236 let old_id = self.conv.session.id.clone();
237 let new_id = session.id.clone();
238 let new_lock = if new_id == old_id {
244 None } else {
246 match Self::try_lock_session(&new_id) {
247 Ok(lock) => Some(lock),
248 Err(e @ agent_core::session_lock::SessionLockError::Held { .. })
249 | Err(e @ agent_core::session_lock::SessionLockError::CompactedInto { .. }) => {
250 self.emit(SessionEventWire::QueryResult {
251 id,
252 value: serde_json::json!({
253 "kind": "error",
254 "text": format!("cannot resume: {e}"),
255 }),
256 });
257 return;
258 }
259 Err(e) => {
260 tracing::warn!(session = %new_id, "resume: session lock: {e}");
263 None
264 }
265 }
266 };
267 self.cache_keepwarm_conversation_replaced();
270 self.runtime.set_model(session.model.clone());
271 let clamp_notice = self
274 .runtime
275 .restore_session_reasoning(&session.thinking_level)
276 .map(|clamp| {
277 format!(
278 "thinking → {} (clamped from {}: not supported by {})",
279 clamp.to.as_str(),
280 clamp.from.as_str(),
281 self.runtime.model()
282 )
283 });
284 if let Some(ref sp) = session.system_prompt {
285 self.runtime.set_system_prompt(sp.clone());
286 }
287 self.save().await;
288 let via = if crate::chain::load_chain(&query).is_ok() {
289 Some(format!("chain '{}'", query))
290 } else if crate::find_session_by_name(&query).is_ok() {
291 Some(format!("name '{}'", query))
292 } else {
293 None
294 };
295 if new_id != old_id {
296 self.session_lock = new_lock;
298 }
299 self.conv = Live::new(crate::engine::session::ConversationState::from_resumed(session));
300 if self.config.persist && self.session_lock.is_some() {
302 crate::engine::setup::recover_turn_draft(&mut self.conv).await;
303 }
304 if clamp_notice.is_some() {
305 self.conv.session.thinking_level = self.runtime.thinking_level().to_string();
307 }
308 self.consecutive_auto_turns = 0;
309 self.runtime.set_session_id(Some(new_id.clone()));
310 self.journal_id.store(Arc::new(new_id.clone()));
311 self.publish_view().await;
312 self.emit(SessionEventWire::Resumed {
313 id,
314 old_id,
315 new_id,
316 via,
317 clamp_notice,
318 });
319 self.emit_conversation();
320 }
321
322 #[allow(dead_code)]
325 pub(crate) fn emit_subagent_rows(&mut self) {
326 let rows = self
327 .runtime
328 .subagent_registry()
329 .lock()
330 .unwrap_or_else(|p| p.into_inner())
331 .display_rows();
332 self.emit(SessionEventWire::SubagentRows(rows));
333 }
334
335 #[allow(dead_code)]
337 pub(crate) fn has_live_subagents(&self) -> bool {
338 self.runtime
339 .subagent_registry()
340 .lock()
341 .unwrap_or_else(|p| p.into_inner())
342 .display_rows()
343 .iter()
344 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use crate::Runtime;
353
354 #[tokio::test]
355 async fn restore_session_reasoning_keeps_explicit_provenance() {
356 let mut runtime = Runtime::new().await.unwrap();
357 runtime.set_model("openai-codex/gpt-5.6-sol".to_string());
358 let clamp = runtime.restore_session_reasoning("ultra");
359 assert!(clamp.is_none());
360 assert_eq!(
361 runtime.reasoning_level(),
362 agent_core::reasoning::ReasoningLevel::Ultra
363 );
364 assert!(runtime.is_reasoning_explicit());
365 runtime.set_model("openai-codex/gpt-5.6-terra".to_string());
366 assert_eq!(
367 runtime.reasoning_level(),
368 agent_core::reasoning::ReasoningLevel::Ultra
369 );
370 }
371
372 #[tokio::test]
373 async fn restore_session_reasoning_clamps_unsupported_level() {
374 let mut runtime = Runtime::new().await.unwrap();
375 runtime.set_model("xai-auth/grok-4.6".to_string());
376 let clamp = runtime.restore_session_reasoning("xhigh");
377 assert!(clamp.is_some());
378 assert_eq!(
379 runtime.reasoning_level(),
380 agent_core::reasoning::ReasoningLevel::High
381 );
382 assert!(runtime.is_reasoning_explicit());
383 }
384}