use crate::{
core::{
auth, model_resolver,
provider::{self, AgentMessage, ModelConfig},
},
extensions::util::{session_id_of, truncate_chars},
modes::interactive::app::App,
};
use std::sync::{Mutex, MutexGuard, OnceLock};
const REASONING_OUTPUT_FLOOR: u32 = 4096;
fn session_slot() -> &'static Mutex<Option<String>> {
static S: OnceLock<Mutex<Option<String>>> = OnceLock::new();
S.get_or_init(|| Mutex::new(None))
}
fn lock_session() -> MutexGuard<'static, Option<String>> {
session_slot().lock().unwrap_or_else(|e| e.into_inner())
}
pub(super) fn remember_session(session_id: Option<String>) {
*lock_session() = session_id;
}
pub(super) fn refresh_session_from_app(st: &App) {
if let Some(id) = session_id_of(st.current_session_path.as_deref()) {
remember_session(Some(id));
}
}
pub(super) fn current_model(st: &App) -> Option<(String, String)> {
match (&st.current_provider, &st.current_model) {
(Some(provider), Some(model_id)) => Some((provider.clone(), model_id.clone())),
_ => None,
}
}
pub(super) fn build_excerpt(
messages: &[AgentMessage],
max_messages: usize,
max_chars: usize,
) -> String {
let mut out = String::new();
let start = messages.len().saturating_sub(max_messages);
for message in &messages[start..] {
let text = message.text();
let text = text.trim();
if text.is_empty() {
continue;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(role_label(&message.role));
out.push_str(": ");
out.push_str(text);
}
truncate_chars(&out, max_chars)
}
fn role_label(role: &str) -> &str {
match role {
"assistant" => "assistant",
"toolResult" => "tool",
"system" => "system",
_ => "user",
}
}
pub(super) fn spawn_one_shot(
provider: String,
model_id: String,
system: &'static str,
prompt: String,
max_tokens: u32,
done: impl FnOnce(Result<String, String>) + Send + 'static,
) -> Result<(), String> {
let handle =
tokio::runtime::Handle::try_current().map_err(|_| "no async runtime".to_string())?;
let session_id = lock_session().clone();
handle.spawn(async move {
done(
one_shot(
&provider, &model_id, session_id, system, &prompt, max_tokens,
)
.await,
);
});
Ok(())
}
async fn one_shot(
provider: &str,
model_id: &str,
session_id: Option<String>,
system: &str,
prompt: &str,
max_tokens: u32,
) -> Result<String, String> {
if let Err(e) = auth::ensure_oauth_valid(provider).await {
return Err(format!("{e}"));
}
let model = model_resolver::configured_model(provider, model_id, None, session_id)
.map_err(|e| format!("{e}"))?;
let max_tokens = output_cap(&model, max_tokens);
let (text, _usage) = provider::simple_completion(&model, system, prompt, Some(max_tokens))
.await
.map_err(|e| format!("{e}"))?;
Ok(text)
}
fn output_cap(model: &ModelConfig, requested: u32) -> u32 {
if !model.reasoning || thinking_can_be_disabled(model) {
return requested;
}
let ceiling = model
.max_tokens
.filter(|m| *m > 0)
.unwrap_or(u32::MAX)
.min(REASONING_OUTPUT_FLOOR);
requested.max(ceiling)
}
fn thinking_can_be_disabled(model: &ModelConfig) -> bool {
model
.thinking_level_map
.as_ref()
.and_then(|m| m.get("off"))
.is_none_or(|v| v.is_some())
}
#[cfg(test)]
pub(super) fn remembered_session() -> Option<String> {
lock_session().clone()
}
#[cfg(test)]
mod tests {
use super::*;
fn message(role: &str, text: &str) -> AgentMessage {
let mut message = AgentMessage::user_text(text);
message.role = role.to_string();
message
}
#[test]
fn excerpt_keeps_recent_messages_and_caps_chars() {
let messages = vec![
message("user", "old"),
message("assistant", "a1"),
message("user", "u2"),
message("assistant", "a3"),
];
assert_eq!(
build_excerpt(&messages, 2, 1000),
"user: u2\nassistant: a3",
"只取最近两条"
);
let long = build_excerpt(&messages, 4, 12);
assert!(
long.chars().count() <= 13,
"总长受字符上限约束(省略号额外占 1 个字符)"
);
assert!(long.starts_with("user: old"), "超长时从头截断");
let tool = build_excerpt(&[message("toolResult", "out")], 1, 100);
assert_eq!(tool, "tool: out", "工具结果用 tool 前缀");
assert_eq!(build_excerpt(&[], 4, 100), "");
assert_eq!(
build_excerpt(
&[message("assistant", " "), message("user", "hi")],
4,
100
),
"user: hi"
);
}
#[test]
fn current_model_reads_tui_mirror() {
let mut st = App::new();
st.current_provider = Some("openai".to_string());
st.current_model = Some("gpt-4".to_string());
assert_eq!(
current_model(&st),
Some(("openai".to_string(), "gpt-4".to_string()))
);
st.current_provider = None;
st.current_model = None;
assert_eq!(
current_model(&st),
None,
"TUI 还没定型号时没有可用的 provider/model"
);
}
#[test]
fn spawn_without_runtime_reports_error_instead_of_panicking() {
let result = spawn_one_shot(
"anthropic".to_string(),
"claude-fable-5".to_string(),
"system",
"prompt".to_string(),
160,
|_| panic!("没有运行时不该调用回调"),
);
assert!(result.is_err(), "无 tokio 运行时应返回 Err");
}
fn model(reasoning: bool, off: Option<Option<&str>>, max_tokens: Option<u32>) -> ModelConfig {
let mut v = serde_json::json!({
"provider": "test",
"model_id": "m",
"base_url": "https://example.com/v1",
"api_key": "",
"input": ["text"],
"reasoning": reasoning,
"max_tokens": max_tokens,
"temperature": null,
"context_window": 100000,
"thinking_format": "deepseek",
"supports_developer_role": false,
"requires_reasoning_content_on_assistant_messages": false,
"max_tokens_field": "max_tokens",
"cost": null,
"supports_usage_in_streaming": true,
"supports_store": false,
"supports_finish_reason": true,
"requires_assistant_after_tool_result": false,
"supports_strict_mode": false,
});
if let Some(off) = off {
v["thinking_level_map"] = serde_json::json!({ "off": off });
}
serde_json::from_value(v).unwrap()
}
#[test]
fn output_cap_raises_only_when_thinking_cannot_be_disabled() {
assert_eq!(output_cap(&model(false, None, None), 160), 160);
assert_eq!(output_cap(&model(true, Some(Some("none")), None), 160), 160);
assert_eq!(output_cap(&model(true, None, None), 160), 160);
assert_eq!(output_cap(&model(true, Some(None), None), 160), 4096);
assert_eq!(output_cap(&model(true, Some(None), Some(2048)), 160), 2048);
assert_eq!(output_cap(&model(true, Some(None), None), 8192), 8192);
assert_eq!(output_cap(&model(true, Some(None), Some(1000)), 8192), 8192);
}
}