use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Extension, Json, Router};
use tracing::error;
use crate::api::AppState;
use crate::auth::{AuthInfo, AuthScope};
use crate::openrouter_models::cached_or_default_openrouter_candidates;
use crate::thought::probe::run_thought_config_probe;
use crate::thought::protocol::{build_sync_request, SyncRequest};
use crate::thought::runtime_config::ThoughtConfig;
use crate::thought_ui::thought_config_ui_metadata;
use crate::types::{ErrorResponse, ThoughtConfigResponse};
async fn get_thought_config(
Extension(auth): Extension<AuthInfo>,
State(state): State<Arc<AppState>>,
) -> Result<Json<ThoughtConfigResponse>, Response> {
auth.require_scope(AuthScope::SessionsRead)?;
let config = state.thought_config.read().await.clone();
Ok(Json(ThoughtConfigResponse {
config,
daemon_defaults: state.daemon_defaults.clone(),
ui: thought_config_ui_metadata(&cached_or_default_openrouter_candidates()),
}))
}
async fn get_thought_sync_preview(
Extension(auth): Extension<AuthInfo>,
State(state): State<Arc<AppState>>,
) -> Result<Json<SyncRequest>, Response> {
auth.require_scope(AuthScope::SessionsRead)?;
let config = state.thought_config.read().await.clone();
let sessions = state.supervisor.collect_session_snapshots().await;
let request = build_sync_request(state.sync_request_sequence.peek_next(), &config, &sessions);
Ok(Json(request))
}
async fn put_thought_config(
Extension(auth): Extension<AuthInfo>,
State(state): State<Arc<AppState>>,
Json(body): Json<ThoughtConfig>,
) -> impl IntoResponse {
if let Err(resp) = auth.require_scope(AuthScope::SessionsWrite) {
return resp;
}
let config = match body.normalize_and_validate() {
Ok(config) => config,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
code: "VALIDATION_FAILED".to_string(),
message: Some(err.to_string()),
}),
)
.into_response();
}
};
let store = match state.file_store.as_ref() {
Some(store) => store,
None => {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
code: "PERSISTENCE_UNAVAILABLE".to_string(),
message: Some("thought config persistence is unavailable".to_string()),
}),
)
.into_response();
}
};
if let Err(err) = store.save_thought_config(&config).await {
error!(error = %err, "failed to persist thought runtime config");
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
code: "INTERNAL_ERROR".to_string(),
message: Some("failed to persist thought config".to_string()),
}),
)
.into_response();
}
{
let mut runtime_config = state.thought_config.write().await;
*runtime_config = config.clone();
}
(StatusCode::OK, Json(config)).into_response()
}
async fn post_thought_config_test(
Extension(auth): Extension<AuthInfo>,
State(_state): State<Arc<AppState>>,
Json(body): Json<ThoughtConfig>,
) -> impl IntoResponse {
if let Err(resp) = auth.require_scope(AuthScope::SessionsWrite) {
return resp;
}
let config = match body.normalize_and_validate() {
Ok(config) => config,
Err(err) => {
return (
StatusCode::BAD_REQUEST,
Json(ErrorResponse {
code: "VALIDATION_FAILED".to_string(),
message: Some(err.to_string()),
}),
)
.into_response();
}
};
(
StatusCode::OK,
Json(run_thought_config_probe(&config).await),
)
.into_response()
}
pub fn routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/v1/thought-config",
get(get_thought_config).put(put_thought_config),
)
.route(
"/v1/thought-config/test",
axum::routing::post(post_thought_config_test),
)
.route("/v1/thought/sync-preview", get(get_thought_sync_preview))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::PublishedSelectionState;
use crate::auth::{AuthScope, OBSERVER_SCOPES, OPERATOR_SCOPES};
use crate::config::Config;
use crate::session::actor::{ActorHandle, SessionCommand};
use crate::session::supervisor::SessionSupervisor;
use crate::thought::protocol::SyncRequestSequence;
use crate::types::{
RestState, SessionState, TerminalSnapshot, ThoughtSource, ThoughtState, TransportHealth,
};
use axum::body::to_bytes;
use axum::extract::{Json, State};
use axum::response::IntoResponse;
use chrono::Utc;
use serde_json::Value;
use tokio::sync::mpsc;
use tokio::sync::RwLock;
fn test_state(
file_store: Option<Arc<crate::persistence::file_store::FileStore>>,
) -> Arc<AppState> {
let config = Arc::new(Config::default());
let supervisor = SessionSupervisor::new(config.clone());
Arc::new(AppState {
supervisor,
config,
thought_config: Arc::new(RwLock::new(ThoughtConfig::default())),
native_desktop_app: Arc::new(RwLock::new(crate::types::NativeDesktopApp::Iterm)),
ghostty_open_mode: Arc::new(RwLock::new(crate::types::GhosttyOpenMode::Swap)),
sync_request_sequence: Arc::new(SyncRequestSequence::new()),
daemon_defaults: None,
file_store,
bridge_health: Arc::new(crate::thought::health::BridgeHealthState::new_with_tick(
std::time::Duration::from_secs(15),
)),
published_selection: Arc::new(RwLock::new(PublishedSelectionState::default())),
repo_actions: crate::host_actions::RepoActionTracker::default(),
})
}
fn summary(session_id: &str, state: SessionState) -> crate::types::SessionSummary {
crate::types::SessionSummary {
session_id: session_id.to_string(),
tmux_name: format!("tmux-{session_id}"),
state,
current_command: None,
cwd: "/tmp/project".to_string(),
tool: Some("Codex".to_string()),
token_count: 55,
context_limit: 100,
thought: Some("reviewing diff".to_string()),
thought_state: ThoughtState::Holding,
thought_source: ThoughtSource::Llm,
thought_updated_at: None,
rest_state: RestState::Drowsy,
commit_candidate: false,
objective_changed_at: None,
last_skill: None,
is_stale: false,
attached_clients: 0,
transport_health: TransportHealth::Healthy,
last_activity_at: Utc::now(),
repo_theme_id: None,
}
}
async fn response_json(response: axum::response::Response) -> Value {
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("response body");
serde_json::from_slice(&body).expect("json body")
}
#[tokio::test]
async fn put_thought_config_rejects_invalid_payloads() {
let response = put_thought_config(
Extension(AuthInfo::new(OPERATOR_SCOPES.to_vec())),
State(test_state(None)),
Json(ThoughtConfig {
cadence_hot_ms: 1,
..ThoughtConfig::default()
}),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let json = response_json(response).await;
assert_eq!(json["code"], "VALIDATION_FAILED");
}
#[tokio::test]
async fn get_thought_config_includes_ui_metadata() {
let response = get_thought_config(
Extension(AuthInfo::new(OBSERVER_SCOPES.to_vec())),
State(test_state(None)),
)
.await
.expect("thought config response");
assert!(!response.0.ui.backends.is_empty());
assert!(response
.0
.ui
.backends
.iter()
.any(|backend| backend.key == "openrouter"));
}
#[tokio::test]
async fn put_thought_config_requires_persistence_store() {
let response = put_thought_config(
Extension(AuthInfo::new(OPERATOR_SCOPES.to_vec())),
State(test_state(None)),
Json(ThoughtConfig::default()),
)
.await
.into_response();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let json = response_json(response).await;
assert_eq!(json["code"], "PERSISTENCE_UNAVAILABLE");
}
#[tokio::test]
async fn get_thought_sync_preview_returns_live_request() {
let state = test_state(None);
{
let mut config = state.thought_config.write().await;
config.agent_prompt = Some("Hook wakeup prompt".to_string());
}
let (cmd_tx, mut cmd_rx) = mpsc::channel(8);
state
.supervisor
.insert_test_handle(ActorHandle::test_handle("sess-1", "tmux-1", cmd_tx))
.await;
tokio::spawn(async move {
while let Some(cmd) = cmd_rx.recv().await {
match cmd {
SessionCommand::GetSummary(reply) => {
let _ = reply.send(summary("sess-1", SessionState::Idle));
}
SessionCommand::GetSnapshot(reply) => {
let _ = reply.send(TerminalSnapshot {
session_id: "sess-1".to_string(),
latest_seq: 9,
truncated: false,
screen_text: "working".to_string(),
});
break;
}
_ => {}
}
}
});
let response = get_thought_sync_preview(
Extension(AuthInfo::new(OBSERVER_SCOPES.to_vec())),
State(state),
)
.await
.expect("preview should succeed");
let json = serde_json::to_value(response.0).expect("preview should serialize");
assert_eq!(json["type"], "sync");
assert_eq!(json["sessions"].as_array().map(|v| v.len()), Some(1));
assert_eq!(json["sessions"][0]["session_id"], "sess-1");
assert_eq!(json["sessions"][0]["replay_text"], "working");
assert_eq!(json["sessions"][0]["rest_state"], "drowsy");
assert_eq!(json["config"]["enabled"], true);
assert_eq!(json["config"]["agent_prompt"], "Hook wakeup prompt");
}
#[tokio::test]
async fn get_thought_sync_preview_handles_zero_sessions() {
let response = get_thought_sync_preview(
Extension(AuthInfo::new(OBSERVER_SCOPES.to_vec())),
State(test_state(None)),
)
.await
.expect("preview should succeed");
let json = serde_json::to_value(response.0).expect("preview should serialize");
assert_eq!(json["type"], "sync");
assert_eq!(json["sessions"], serde_json::json!([]));
}
#[tokio::test]
async fn get_thought_sync_preview_requires_read_scope() {
let response = get_thought_sync_preview(
Extension(AuthInfo::new(vec![AuthScope::SessionsWrite])),
State(test_state(None)),
)
.await
.expect_err("preview should require read scope");
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn get_thought_sync_preview_is_read_only_for_request_ids() {
let state = test_state(None);
let first = get_thought_sync_preview(
Extension(AuthInfo::new(OBSERVER_SCOPES.to_vec())),
State(state.clone()),
)
.await
.expect("first preview should succeed");
let second = get_thought_sync_preview(
Extension(AuthInfo::new(OBSERVER_SCOPES.to_vec())),
State(state.clone()),
)
.await
.expect("second preview should succeed");
assert_eq!(first.0.id, "1");
assert_eq!(second.0.id, "1");
assert_eq!(state.sync_request_sequence.peek_next(), 1);
}
}