use sqlx::Row;

use super::StorageError;
use crate::bus::{ClientVisibility, TurnOrigin};

/// Event kinds materialized in the durable inbox.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentEventType {
    Signal,
    Completion,
}

impl AgentEventType {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Signal => "signal",
            Self::Completion => "completion",
        }
    }

    pub fn parse(value: &str) -> Result<Self, StorageError> {
        match value {
            "signal" => Ok(Self::Signal),
            "completion" => Ok(Self::Completion),
            other => Err(StorageError::Migration(format!(
                "corrupt agent event type '{other}'"
            ))),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentEventDelivery {
    Queue,
    Steer,
}

impl AgentEventDelivery {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Queue => "queue",
            Self::Steer => "steer",
        }
    }

    pub fn parse(value: &str) -> Result<Self, StorageError> {
        match value {
            "queue" => Ok(Self::Queue),
            "steer" => Ok(Self::Steer),
            other => Err(StorageError::Migration(format!(
                "corrupt agent event delivery '{other}'"
            ))),
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentEventStatus {
    Pending,
    Leased,
    Admitted,
    Consumed,
    Superseded,
    DeadLetter,
}

impl AgentEventStatus {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Pending => "pending",
            Self::Leased => "leased",
            Self::Admitted => "admitted",
            Self::Consumed => "consumed",
            Self::Superseded => "superseded",
            Self::DeadLetter => "dead_letter",
        }
    }

    pub fn parse(value: &str) -> Result<Self, StorageError> {
        match value {
            "pending" => Ok(Self::Pending),
            "leased" => Ok(Self::Leased),
            "admitted" => Ok(Self::Admitted),
            "consumed" => Ok(Self::Consumed),
            "superseded" => Ok(Self::Superseded),
            "dead_letter" => Ok(Self::DeadLetter),
            other => Err(StorageError::Migration(format!(
                "corrupt agent event status '{other}'"
            ))),
        }
    }
}

#[derive(Debug, Clone)]
pub struct AgentInboxEventRecord {
    pub id: String,
    pub root_session_id: String,
    pub run_id: Option<String>,
    pub event_type: AgentEventType,
    pub event_key: String,
    pub delivery: AgentEventDelivery,
    pub requires_continuation: bool,
    pub severity: Option<String>,
    pub payload_json: String,
    pub status: AgentEventStatus,
    pub attempt_count: i64,
    pub lease_token: Option<String>,
    pub lease_until: Option<i64>,
    pub next_attempt_at: Option<i64>,
    pub admitted_turn_id: Option<String>,
    pub last_error: Option<String>,
    pub revision: i64,
    pub created_at: i64,
    pub consumed_at: Option<i64>,
    pub superseded_at: Option<i64>,
    pub dead_lettered_at: Option<i64>,
    pub fallback_notified_at: Option<i64>,
    pub fallback_suppressed_reason: Option<String>,
}

#[derive(Debug, Clone)]
pub struct NewInboxEvent {
    pub id: String,
    pub root_session_id: String,
    pub run_id: Option<String>,
    pub event_type: AgentEventType,
    pub event_key: String,
    pub delivery: AgentEventDelivery,
    pub requires_continuation: bool,
    pub severity: Option<String>,
    pub payload_json: String,
}

/// A claimed batch of due events.  The lease token owns the transition back
/// to `pending` on failure; only process crashes rely on lease expiry.
#[derive(Debug)]
pub struct InboxLease {
    pub token: String,
    pub events: Vec<AgentInboxEventRecord>,
}

/// Steer events admitted to a user Turn; consumed atomically with the Turn's
/// durable commit.
#[derive(Debug, Clone, Default)]
pub struct SteerConsumption {
    pub event_ids: Vec<String>,
    pub admitted_turn_id: String,
}

#[derive(Debug, Default)]
pub struct RecoveryReport {
    pub interrupted_runs: usize,
    pub completion_events_generated: usize,
    pub leases_expired: usize,
    pub dead_lettered: usize,
    pub sessions_reconciled: usize,
}

const EVENT_COLUMNS: &str = "id, root_session_id, run_id, event_type, event_key, delivery, \
    requires_continuation, severity, payload_json, status, attempt_count, lease_token, \
    lease_until, next_attempt_at, admitted_turn_id, last_error, revision, created_at, \
    consumed_at, superseded_at, dead_lettered_at, fallback_notified_at, \
    fallback_suppressed_reason";

fn event_record_from_row(
    row: &sqlx::sqlite::SqliteRow,
) -> Result<AgentInboxEventRecord, StorageError> {
    Ok(AgentInboxEventRecord {
        id: row.get("id"),
        root_session_id: row.get("root_session_id"),
        run_id: row.get("run_id"),
        event_type: AgentEventType::parse(row.get::<&str, _>("event_type"))?,
        event_key: row.get("event_key"),
        delivery: AgentEventDelivery::parse(row.get::<&str, _>("delivery"))?,
        requires_continuation: row.get::<i64, _>("requires_continuation") != 0,
        severity: row.get("severity"),
        payload_json: row.get("payload_json"),
        status: AgentEventStatus::parse(row.get::<&str, _>("status"))?,
        attempt_count: row.get("attempt_count"),
        lease_token: row.get("lease_token"),
        lease_until: row.get("lease_until"),
        next_attempt_at: row.get("next_attempt_at"),
        admitted_turn_id: row.get("admitted_turn_id"),
        last_error: row.get("last_error"),
        revision: row.get("revision"),
        created_at: row.get("created_at"),
        consumed_at: row.get("consumed_at"),
        superseded_at: row.get("superseded_at"),
        dead_lettered_at: row.get("dead_lettered_at"),
        fallback_notified_at: row.get("fallback_notified_at"),
        fallback_suppressed_reason: row.get("fallback_suppressed_reason"),
    })
}

impl super::Storage {
    /// Ensure the authoritative inbox capacity/revision row exists.
    pub async fn ensure_agent_session_state(
        &self,
        root_session_id: &str,
        now: i64,
    ) -> Result<(), StorageError> {
        sqlx::query(
            "INSERT INTO agent_session_state (root_session_id, revision, pending_event_count, \
                reserved_completion_slots, updated_at) VALUES (?, 0, 0, 0, ?) \
             ON CONFLICT(root_session_id) DO NOTHING",
        )
        .bind(root_session_id)
        .bind(now)
        .execute(self.pool())
        .await?;
        Ok(())
    }

    /// Reserve `count` completion slots for background runs whose completion
    /// is guaranteed even when the inbox is full.  Returns the new revision,
    /// or `None` when the capacity check fails (nothing is reserved).
    pub async fn reserve_completion_slots(
        &self,
        root_session_id: &str,
        count: i64,
        max_pending_and_reserved: i64,
        now: i64,
    ) -> Result<Option<i64>, StorageError> {
        let mut tx = self.pool.begin().await?;
        ensure_agent_session_state_tx(&mut tx, root_session_id, now).await?;
        let revision: Option<i64> = sqlx::query_scalar(
            "UPDATE agent_session_state \
             SET reserved_completion_slots = reserved_completion_slots + ?, \
                 revision = revision + 1, updated_at = ? \
             WHERE root_session_id = ? \
               AND pending_event_count + reserved_completion_slots + ? <= ? \
             RETURNING revision",
        )
        .bind(count)
        .bind(now)
        .bind(root_session_id)
        .bind(count)
        .bind(max_pending_and_reserved)
        .fetch_optional(&mut *tx)
        .await?;
        tx.commit().await?;
        Ok(revision)
    }

    /// Release reservation slots that are no longer owned (compensation after
    /// spawn rejection, session archive, recovery).  Never negative.
    pub async fn release_completion_slots(
        &self,
        root_session_id: &str,
        count: i64,
        now: i64,
    ) -> Result<(), StorageError> {
        sqlx::query(
            "UPDATE agent_session_state \
             SET reserved_completion_slots = MAX(reserved_completion_slots - ?, 0), \
                 revision = revision + 1, updated_at = ? \
             WHERE root_session_id = ?",
        )
        .bind(count)
        .bind(now)
        .bind(root_session_id)
        .execute(self.pool())
        .await?;
        Ok(())
    }

    /// Insert a signal event.  Capacity is enforced conditionally; the slot
    /// is accounted against `pending + reserved`, so background completion
    /// reservations are never displaced by signals.  Returns `None` when the
    /// inbox is full.
    /// Persist a `signal` event under the session inbox capacity.  A signal
    /// whose dedupe key already exists (same scope/type/key, i.e. the same
    /// cooldown window) returns the existing event with `deduplicated=true`
    /// instead of consuming capacity or emitting a second event.
    pub async fn insert_agent_signal(
        &self,
        event: &NewInboxEvent,
        max_pending_and_reserved: i64,
        now: i64,
    ) -> Result<Option<(AgentInboxEventRecord, bool)>, StorageError> {
        let mut tx = self.pool.begin().await?;
        ensure_agent_session_state_tx(&mut tx, &event.root_session_id, now).await?;
        if let Some(existing_id) = sqlx::query_scalar::<_, String>(
            "SELECT id FROM agent_inbox_events \
             WHERE run_id = ? AND event_type = ? AND event_key = ?",
        )
        .bind(&event.run_id)
        .bind(event.event_type.as_str())
        .bind(&event.event_key)
        .fetch_optional(&mut *tx)
        .await?
        {
            tx.commit().await?;
            let record = self.get_agent_inbox_event(&existing_id).await?;
            return Ok(record.map(|record| (record, true)));
        }
        let revision: Option<i64> = sqlx::query_scalar(
            "UPDATE agent_session_state \
             SET pending_event_count = pending_event_count + 1, \
                 revision = revision + 1, updated_at = ? \
             WHERE root_session_id = ? \
               AND pending_event_count + reserved_completion_slots + 1 <= ? \
             RETURNING revision",
        )
        .bind(now)
        .bind(&event.root_session_id)
        .bind(max_pending_and_reserved)
        .fetch_optional(&mut *tx)
        .await?;
        let Some(revision) = revision else {
            return Ok(None);
        };
        insert_event_tx(&mut tx, event, revision, now).await?;
        tx.commit().await?;
        let record = self.get_agent_inbox_event(&event.id).await?;
        Ok(record.map(|record| (record, false)))
    }

    pub async fn get_agent_inbox_event(
        &self,
        event_id: &str,
    ) -> Result<Option<AgentInboxEventRecord>, StorageError> {
        let row = sqlx::query(sqlx::AssertSqlSafe(format!(
            "SELECT {EVENT_COLUMNS} FROM agent_inbox_events WHERE id = ?"
        )))
        .bind(event_id)
        .fetch_optional(self.pool())
        .await?;
        match row {
            Some(row) => Ok(Some(event_record_from_row(&row)?)),
            None => Ok(None),
        }
    }

    pub async fn list_agent_inbox_events(
        &self,
        root_session_id: &str,
        limit: i64,
    ) -> Result<Vec<AgentInboxEventRecord>, StorageError> {
        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
            "SELECT {EVENT_COLUMNS} FROM agent_inbox_events \
             WHERE root_session_id = ? ORDER BY created_at DESC, id DESC LIMIT ?"
        )))
        .bind(root_session_id)
        .bind(limit.clamp(1, 200))
        .fetch_all(self.pool())
        .await?;
        rows.iter().map(event_record_from_row).collect()
    }

    /// Inbox events of one run, newest first (audit/projection queries).
    pub async fn list_agent_inbox_events_for_run(
        &self,
        run_id: &str,
        limit: i64,
    ) -> Result<Vec<AgentInboxEventRecord>, StorageError> {
        let rows = sqlx::query(sqlx::AssertSqlSafe(format!(
            "SELECT {EVENT_COLUMNS} FROM agent_inbox_events \
             WHERE run_id = ? ORDER BY created_at DESC, id DESC LIMIT ?"
        )))
        .bind(run_id)
        .bind(limit.clamp(1, 200))
        .fetch_all(self.pool())
        .await?;
        rows.iter().map(event_record_from_row).collect()
    }

    /// Current client-visible revision of a session's Agent projection.
    /// `0` when the session never had Agent state.
    pub async fn get_session_agent_revision(
        &self,
        root_session_id: &str,
    ) -> Result<i64, StorageError> {
        let revision: Option<i64> = sqlx::query_scalar(
            "SELECT revision FROM agent_session_state WHERE root_session_id = ?",
        )
        .bind(root_session_id)
        .fetch_optional(self.pool())
        .await?;
        Ok(revision.unwrap_or(0))
    }

    /// Oldest `created_at` among currently due pending events, used by the
    /// fairness policy to bound how long an event can wait behind user input.
    /// `None` when no event is currently due.
    pub async fn oldest_pending_due(
        &self,
        root_session_id: &str,
        now: i64,
    ) -> Result<Option<i64>, StorageError> {
        let value: Option<Option<i64>> = sqlx::query_scalar::<_, Option<i64>>(
            "SELECT MIN(created_at) FROM agent_inbox_events \
             WHERE root_session_id = ? AND status = 'pending' AND next_attempt_at <= ?",
        )
        .bind(root_session_id)
        .bind(now)
        .fetch_optional(self.pool())
        .await?;
        Ok(value.flatten())
    }

    /// Earliest `next_attempt_at` among pending events — the deadline at
    /// which the worker must re-claim after a release backoff.  `None` when
    /// nothing is pending.
    pub async fn next_pending_due_at(
        &self,
        root_session_id: &str,
    ) -> Result<Option<i64>, StorageError> {
        let value: Option<Option<i64>> = sqlx::query_scalar::<_, Option<i64>>(
            "SELECT MIN(next_attempt_at) FROM agent_inbox_events \
             WHERE root_session_id = ? AND status = 'pending'",
        )
        .bind(root_session_id)
        .fetch_optional(self.pool())
        .await?;
        Ok(value.flatten())
    }

    /// Dead-letter leased/admitted events that exhausted their delivery
    /// attempts during normal operation (not only at recovery), reducing the
    /// pending count by the number actually dead-lettered.
    pub async fn dead_letter_leased_events(
        &self,
        lease_token: &str,
        event_ids: &[String],
        reason: &str,
        now: i64,
    ) -> Result<usize, StorageError> {
        if event_ids.is_empty() {
            return Ok(0);
        }
        let mut tx = self.pool.begin().await?;
        let mut count = 0i64;
        for id in event_ids {
            count += sqlx::query(
                "UPDATE agent_inbox_events \
                 SET status = 'dead_letter', dead_lettered_at = ?, last_error = ?, \
                     lease_token = NULL, lease_until = NULL, updated_at = ? \
                 WHERE id = ? AND lease_token = ? AND status IN ('leased', 'admitted')",
            )
            .bind(now)
            .bind(reason)
            .bind(now)
            .bind(id)
            .bind(lease_token)
            .execute(&mut *tx)
            .await?
            .rows_affected() as i64;
        }
        if count > 0 {
            let session: Option<String> =
                sqlx::query_scalar("SELECT root_session_id FROM agent_inbox_events WHERE id = ?")
                    .bind(&event_ids[0])
                    .fetch_optional(&mut *tx)
                    .await?;
            if let Some(session) = session {
                sqlx::query(
                    "UPDATE agent_session_state \
                     SET pending_event_count = MAX(pending_event_count - ?, 0), \
                         revision = revision + 1, updated_at = ? \
                     WHERE root_session_id = ?",
                )
                .bind(count)
                .bind(now)
                .bind(session)
                .execute(&mut *tx)
                .await?;
            }
        }
        tx.commit().await?;
        Ok(count as usize)
    }

    /// Sessions that currently have due pending events, for the merged
    /// post-recovery wake.
    pub async fn sessions_with_due_events(&self, now: i64) -> Result<Vec<String>, StorageError> {
        let ids: Vec<String> = sqlx::query_scalar(
            "SELECT DISTINCT root_session_id FROM agent_inbox_events \
             WHERE status = 'pending' AND next_attempt_at <= ?",
        )
        .bind(now)
        .fetch_all(self.pool())
        .await?;
        Ok(ids)
    }

    /// Claim the oldest due events for a session as one leased batch, bounded
    /// by `max_events` and `max_payload_bytes`.  Returns `None` when nothing
    /// is due.  The lease token must be presented to release or consume.
    pub async fn claim_inbox_batch(
        &self,
        root_session_id: &str,
        now: i64,
        lease_ms: i64,
        max_events: usize,
        max_payload_bytes: usize,
        delivery: Option<AgentEventDelivery>,
    ) -> Result<Option<InboxLease>, StorageError> {
        let candidates = match delivery {
            None => {
                sqlx::query(
                    "SELECT id, payload_json FROM agent_inbox_events \
                     WHERE root_session_id = ? AND status = 'pending' AND next_attempt_at <= ? \
                     ORDER BY created_at ASC, id ASC LIMIT ?",
                )
                .bind(root_session_id)
                .bind(now)
                .bind(max_events as i64)
                .fetch_all(self.pool())
                .await?
            }
            Some(delivery) => {
                sqlx::query(
                    "SELECT id, payload_json FROM agent_inbox_events \
                     WHERE root_session_id = ? AND status = 'pending' AND next_attempt_at <= ? \
                       AND delivery = ? \
                     ORDER BY created_at ASC, id ASC LIMIT ?",
                )
                .bind(root_session_id)
                .bind(now)
                .bind(delivery.as_str())
                .bind(max_events as i64)
                .fetch_all(self.pool())
                .await?
            }
        };

        let mut chosen = Vec::new();
        let mut bytes = 0usize;
        for row in candidates {
            let id: String = row.get("id");
            let payload: String = row.get("payload_json");
            if !chosen.is_empty() && bytes.saturating_add(payload.len()) > max_payload_bytes {
                break;
            }
            bytes = bytes.saturating_add(payload.len());
            chosen.push(id);
        }
        if chosen.is_empty() {
            return Ok(None);
        }

        let token = uuid::Uuid::new_v4().to_string();
        let lease_until = now + lease_ms;
        let mut tx = self.pool.begin().await?;
        let mut events = Vec::new();
        for id in &chosen {
            let updated = sqlx::query(
                "UPDATE agent_inbox_events \
                 SET status = 'leased', lease_token = ?, lease_until = ?, \
                     attempt_count = attempt_count + 1, updated_at = ? \
                 WHERE id = ? AND status = 'pending'",
            )
            .bind(&token)
            .bind(lease_until)
            .bind(now)
            .bind(id)
            .execute(&mut *tx)
            .await?
            .rows_affected();
            if updated == 1 {
                let row = sqlx::query(sqlx::AssertSqlSafe(format!(
                    "SELECT {EVENT_COLUMNS} FROM agent_inbox_events WHERE id = ?"
                )))
                .bind(id)
                .fetch_one(&mut *tx)
                .await?;
                events.push(event_record_from_row(&row)?);
            }
        }
        tx.commit().await?;
        if events.is_empty() {
            return Ok(None);
        }
        Ok(Some(InboxLease { token, events }))
    }

    /// Transition a leased event into `admitted` for a specific Turn.  Used
    /// by the steer lane; the queue lane consumes directly.
    pub async fn admit_inbox_event(
        &self,
        event_id: &str,
        lease_token: &str,
        turn_id: &str,
        now: i64,
    ) -> Result<bool, StorageError> {
        let updated = sqlx::query(
            "UPDATE agent_inbox_events SET status = 'admitted', admitted_turn_id = ?, updated_at = ? \
             WHERE id = ? AND lease_token = ? AND status = 'leased'",
        )
        .bind(turn_id)
        .bind(now)
        .bind(event_id)
        .bind(lease_token)
        .execute(self.pool())
        .await?
        .rows_affected();
        Ok(updated == 1)
    }

    /// Return leased/admitted events to `pending` with a retry backoff.  The
    /// lease token prevents double release of events owned by another worker.
    pub async fn release_inbox_lease(
        &self,
        lease_token: &str,
        event_ids: &[String],
        retry_after_ms: i64,
        error: Option<&str>,
        now: i64,
    ) -> Result<(), StorageError> {
        if event_ids.is_empty() {
            return Ok(());
        }
        let mut tx = self.pool.begin().await?;
        for id in event_ids {
            sqlx::query(
                "UPDATE agent_inbox_events \
                 SET status = 'pending', lease_token = NULL, lease_until = NULL, \
                     next_attempt_at = ?, last_error = ?, updated_at = ? \
                 WHERE id = ? AND lease_token = ? AND status IN ('leased', 'admitted')",
            )
            .bind(now + retry_after_ms)
            .bind(error)
            .bind(now)
            .bind(id)
            .bind(lease_token)
            .execute(&mut *tx)
            .await?;
        }
        tx.commit().await?;
        Ok(())
    }

    /// Supersede unconsumed signal events of a run (explicit cancel).  The
    /// pending count is reduced by the number of events actually superseded.
    pub async fn supersede_agent_events(
        &self,
        run_id: &str,
        event_type: AgentEventType,
        now: i64,
    ) -> Result<usize, StorageError> {
        let mut tx = self.pool.begin().await?;
        let ids: Vec<String> = sqlx::query_scalar(
            "SELECT id FROM agent_inbox_events \
             WHERE run_id = ? AND event_type = ? AND status IN ('pending', 'leased', 'admitted')",
        )
        .bind(run_id)
        .bind(event_type.as_str())
        .fetch_all(&mut *tx)
        .await?;
        if ids.is_empty() {
            return Ok(0);
        }
        let mut count = 0i64;
        for id in &ids {
            count += sqlx::query(
                "UPDATE agent_inbox_events SET status = 'superseded', superseded_at = ?, updated_at = ? \
                 WHERE id = ? AND status IN ('pending', 'leased', 'admitted')",
            )
            .bind(now)
            .bind(now)
            .bind(id)
            .execute(&mut *tx)
            .await?
            .rows_affected() as i64;
        }
        if count > 0 {
            let session: Option<String> =
                sqlx::query_scalar("SELECT root_session_id FROM agent_inbox_events WHERE id = ?")
                    .bind(&ids[0])
                    .fetch_optional(&mut *tx)
                    .await?;
            if let Some(session) = session {
                sqlx::query(
                    "UPDATE agent_session_state \
                     SET pending_event_count = MAX(pending_event_count - ?, 0), \
                         revision = revision + 1, updated_at = ? \
                     WHERE root_session_id = ?",
                )
                .bind(count)
                .bind(now)
                .bind(session)
                .execute(&mut *tx)
                .await?;
            }
        }
        tx.commit().await?;
        Ok(count as usize)
    }

    /// Dead-letter every unconsumed event of a session (archive/delete) and
    /// zero the pending count.  No continuation is started afterwards.
    pub async fn dead_letter_agent_events(
        &self,
        root_session_id: &str,
        reason: &str,
        now: i64,
    ) -> Result<usize, StorageError> {
        let mut tx = self.pool.begin().await?;
        let updated = sqlx::query(
            "UPDATE agent_inbox_events \
             SET status = 'dead_letter', dead_lettered_at = ?, \
                 fallback_suppressed_reason = ?, updated_at = ? \
             WHERE root_session_id = ? AND status IN ('pending', 'leased', 'admitted')",
        )
        .bind(now)
        .bind(reason)
        .bind(now)
        .bind(root_session_id)
        .execute(&mut *tx)
        .await?
        .rows_affected();
        sqlx::query(
            "UPDATE agent_session_state SET pending_event_count = 0, \
                 revision = revision + 1, updated_at = ? WHERE root_session_id = ?",
        )
        .bind(now)
        .bind(root_session_id)
        .execute(&mut *tx)
        .await?;
        tx.commit().await?;
        Ok(updated as usize)
    }

    /// Atomically commit a continuation Turn: hidden trigger, assistant/tool
    /// messages, usage, and consumption of the leased events in one
    /// transaction.  The `sessions` row and `agent_session_state` counts are
    /// updated in the same commit so a client never observes a partially
    /// materialized continuation.
    #[allow(clippy::too_many_arguments)]
    pub async fn commit_continuation_turn(
        &self,
        session_id: &str,
        trigger: &crate::storage::message::MessageMeta,
        messages: &[crate::storage::message::MessageMeta],
        meta: &crate::storage::session::SessionMeta,
        usage: Option<&crate::storage::TurnUsageRecord>,
        lease_token: &str,
        event_ids: &[String],
        now: i64,
    ) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
        let mut tx = self.pool.begin().await?;
        super::insert_message_query(session_id, trigger)
            .execute(&mut *tx)
            .await?;
        for message in messages {
            super::insert_message_query(session_id, message)
                .execute(&mut *tx)
                .await?;
        }
        sqlx::query(
            r#"
            INSERT INTO sessions (id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at)
            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(id) DO UPDATE SET
                title = excluded.title,
                last_active_at = excluded.last_active_at,
                message_count = excluded.message_count,
                routing_info = excluded.routing_info,
                archived_at = excluded.archived_at,
                deleted_at = excluded.deleted_at,
                last_consolidated_at = excluded.last_consolidated_at,
                last_compressed_message_at = excluded.last_compressed_message_at
            "#,
        )
        .bind(&meta.id)
        .bind(&meta.channel)
        .bind(&meta.chat_id)
        .bind(&meta.dialog_id)
        .bind(&meta.title)
        .bind(meta.created_at)
        .bind(meta.last_active_at)
        .bind(meta.message_count)
        .bind(&meta.routing_info)
        .bind(meta.archived_at)
        .bind(meta.deleted_at)
        .bind(meta.last_consolidated_at)
        .bind(meta.last_compressed_message_at)
        .execute(&mut *tx)
        .await?;

        if let Some(usage) = usage {
            let request_count = messages
                .iter()
                .filter_map(|message| message.iteration)
                .max()
                .map_or(1_i64, |iteration| iteration.saturating_add(1));
            sqlx::query(
                r#"
                INSERT INTO session_turn_usage (
                    turn_id, session_id, provider, model, prompt_tokens,
                    completion_tokens, total_tokens, cached_input_tokens,
                    request_count, last_prompt_tokens, created_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(turn_id) DO NOTHING
                "#,
            )
            .bind(&usage.turn_id)
            .bind(&usage.session_id)
            .bind(&usage.provider)
            .bind(&usage.model)
            .bind(i64::from(usage.usage.prompt_tokens))
            .bind(i64::from(usage.usage.completion_tokens))
            .bind(i64::from(usage.usage.total_tokens))
            .bind(usage.usage.cached_tokens.map(i64::from))
            .bind(request_count)
            .bind(i64::from(usage.last_prompt_tokens))
            .bind(usage.created_at)
            .execute(&mut *tx)
            .await?;
        }

        let consumed = event_ids.len() as i64;
        for event_id in event_ids {
            sqlx::query(
                "UPDATE agent_inbox_events SET status = 'consumed', consumed_at = ?, updated_at = ? \
                 WHERE id = ? AND lease_token = ? AND status IN ('leased', 'admitted')",
            )
            .bind(now)
            .bind(now)
            .bind(event_id)
            .bind(lease_token)
            .execute(&mut *tx)
            .await?;
        }
        sqlx::query(
            "UPDATE agent_session_state \
             SET pending_event_count = MAX(pending_event_count - ?, 0), \
                 revision = revision + 1, updated_at = ? \
             WHERE root_session_id = ?",
        )
        .bind(consumed)
        .bind(now)
        .bind(session_id)
        .execute(&mut *tx)
        .await?;

        tx.commit().await?;
        let mut committed = Vec::with_capacity(messages.len());
        for message in messages {
            committed.push(message.clone());
        }
        Ok(committed)
    }

    /// Reconcile agent state after a restart or new runtime generation.
    ///
    /// 1. Old-generation queued/running/waiting_children runs become
    ///    `interrupted`; background runs with reserved completion slots get a
    ///    failure completion event (the reservation is converted).
    /// 2. Expired leases return to `pending` with a backoff; attempts beyond
    ///    the maximum become `dead_letter`.
    /// 3. Per-session capacity counters are reconciled with the rows.
    pub async fn recover_agent_state(
        &self,
        active_generation: i64,
        now: i64,
        max_delivery_attempts: i64,
        retry_backoff_ms: i64,
    ) -> Result<RecoveryReport, StorageError> {
        let mut report = RecoveryReport::default();
        let mut tx = self.pool.begin().await?;

        // 1. Interrupt runs of previous generations.
        let interrupted: Vec<(String, String, i64, String, String)> = sqlx::query_as(
            "SELECT id, root_session_id, completion_slot_reserved, agent_id, task FROM agent_runs \
             WHERE runtime_generation != ? AND status IN ('queued', 'running', 'waiting_children')",
        )
        .bind(active_generation)
        .fetch_all(&mut *tx)
        .await?;
        for (run_id, session_id, reserved, agent_id, task) in &interrupted {
            let updated = sqlx::query(
                "UPDATE agent_runs SET status = 'interrupted', error = ?, finished_at = ?, updated_at = ? \
                 WHERE id = ? AND status IN ('queued', 'running', 'waiting_children')",
            )
            .bind("interrupted by runtime generation handover")
            .bind(now)
            .bind(now)
            .bind(run_id)
            .execute(&mut *tx)
            .await?
            .rows_affected();
            if updated != 1 {
                continue;
            }
            report.interrupted_runs += 1;
            if *reserved != 0
                && let Some(revision) = convert_reservation_tx(&mut tx, session_id, now).await?
            {
                let event = NewInboxEvent {
                    id: uuid::Uuid::new_v4().to_string(),
                    root_session_id: session_id.clone(),
                    run_id: Some(run_id.clone()),
                    event_type: AgentEventType::Completion,
                    event_key: format!("interrupted:{run_id}"),
                    delivery: AgentEventDelivery::Queue,
                    requires_continuation: true,
                    severity: Some("error".to_string()),
                    payload_json: completion_payload(
                        run_id,
                        agent_id,
                        task,
                        None,
                        "interrupted",
                        Some("interrupted by runtime generation handover"),
                        &[],
                    ),
                };
                insert_event_tx(&mut tx, &event, revision, now).await?;
                report.completion_events_generated += 1;
            }
        }

        // 2. Expire stale leases.
        let expired: Vec<String> = sqlx::query_scalar(
            "SELECT id FROM agent_inbox_events WHERE status = 'leased' AND lease_until <= ?",
        )
        .bind(now)
        .fetch_all(&mut *tx)
        .await?;
        for id in &expired {
            let updated = sqlx::query(
                "UPDATE agent_inbox_events SET attempt_count = attempt_count + 1, last_error = ?, updated_at = ? \
                 WHERE id = ? AND status = 'leased'",
            )
            .bind("lease expired")
            .bind(now)
            .bind(id)
            .execute(&mut *tx)
            .await?
            .rows_affected();
            if updated != 1 {
                continue;
            }
            report.leases_expired += 1;
            let attempts: i64 =
                sqlx::query_scalar("SELECT attempt_count FROM agent_inbox_events WHERE id = ?")
                    .bind(id)
                    .fetch_one(&mut *tx)
                    .await?;
            if attempts >= max_delivery_attempts {
                sqlx::query(
                    "UPDATE agent_inbox_events SET status = 'dead_letter', dead_lettered_at = ?, updated_at = ? \
                     WHERE id = ? AND status = 'leased'",
                )
                .bind(now)
                .bind(now)
                .bind(id)
                .execute(&mut *tx)
                .await?;
                report.dead_lettered += 1;
            } else {
                sqlx::query(
                    "UPDATE agent_inbox_events SET status = 'pending', lease_token = NULL, lease_until = NULL, \
                        next_attempt_at = ?, updated_at = ? \
                     WHERE id = ? AND status = 'leased'",
                )
                .bind(now + retry_backoff_ms * attempts)
                .bind(now)
                .bind(id)
                .execute(&mut *tx)
                .await?;
            }
        }

        // 3. Reconcile per-session capacity counters.
        let sessions: Vec<(String, i64, i64)> = sqlx::query_as(
            "SELECT root_session_id, \
                (SELECT COUNT(*) FROM agent_runs r WHERE r.root_session_id = s.root_session_id AND r.completion_slot_reserved = 1), \
                (SELECT COUNT(*) FROM agent_inbox_events e WHERE e.root_session_id = s.root_session_id AND e.status IN ('pending','leased','admitted')) \
             FROM agent_session_state s",
        )
        .fetch_all(&mut *tx)
        .await?;
        for (session_id, expected_reserved, expected_pending) in sessions {
            let actual: (i64, i64) = sqlx::query_as(
                "SELECT reserved_completion_slots, pending_event_count FROM agent_session_state WHERE root_session_id = ?",
            )
            .bind(&session_id)
            .fetch_one(&mut *tx)
            .await?;
            if actual.0 != expected_reserved || actual.1 != expected_pending {
                sqlx::query(
                    "UPDATE agent_session_state SET reserved_completion_slots = ?, pending_event_count = ?, \
                        revision = revision + 1, updated_at = ? WHERE root_session_id = ?",
                )
                .bind(expected_reserved)
                .bind(expected_pending)
                .bind(now)
                .bind(&session_id)
                .execute(&mut *tx)
                .await?;
                report.sessions_reconciled += 1;
                tracing::warn!(
                    session_id = %session_id,
                    before_reserved = actual.0,
                    before_pending = actual.1,
                    "agent session state counters diverged; reconciled"
                );
            }
        }

        tx.commit().await?;
        Ok(report)
    }
}

async fn ensure_agent_session_state_tx(
    tx: &mut sqlx::SqliteConnection,
    root_session_id: &str,
    now: i64,
) -> Result<(), StorageError> {
    sqlx::query(
        "INSERT INTO agent_session_state (root_session_id, revision, pending_event_count, \
            reserved_completion_slots, updated_at) VALUES (?, 0, 0, 0, ?) \
         ON CONFLICT(root_session_id) DO NOTHING",
    )
    .bind(root_session_id)
    .bind(now)
    .execute(&mut *tx)
    .await?;
    Ok(())
}

/// Convert one reserved completion slot into a pending event slot.  Returns
/// the new revision to stamp the event with.
async fn convert_reservation_tx(
    tx: &mut sqlx::SqliteConnection,
    root_session_id: &str,
    now: i64,
) -> Result<Option<i64>, StorageError> {
    let revision: Option<i64> = sqlx::query_scalar(
        "UPDATE agent_session_state \
         SET reserved_completion_slots = MAX(reserved_completion_slots - 1, 0), \
             pending_event_count = pending_event_count + 1, \
             revision = revision + 1, updated_at = ? \
         WHERE root_session_id = ? RETURNING revision",
    )
    .bind(now)
    .bind(root_session_id)
    .fetch_optional(&mut *tx)
    .await?;
    Ok(revision)
}

pub(crate) async fn insert_event_tx(
    tx: &mut sqlx::SqliteConnection,
    event: &NewInboxEvent,
    revision: i64,
    now: i64,
) -> Result<(), StorageError> {
    sqlx::query(
        "INSERT INTO agent_inbox_events (id, root_session_id, run_id, event_type, event_key, \
            delivery, requires_continuation, severity, payload_json, status, attempt_count, \
            revision, next_attempt_at, created_at, updated_at) \
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?, ?)",
    )
    .bind(&event.id)
    .bind(&event.root_session_id)
    .bind(&event.run_id)
    .bind(event.event_type.as_str())
    .bind(&event.event_key)
    .bind(event.delivery.as_str())
    .bind(i64::from(event.requires_continuation))
    .bind(&event.severity)
    .bind(&event.payload_json)
    .bind(revision)
    .bind(now)
    .bind(now)
    .bind(now)
    .execute(&mut *tx)
    .await?;
    Ok(())
}

/// Helper used by `commit_agent_terminal` to materialize a completion event
/// for a background run that reserved a slot.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn insert_completion_event_tx(
    tx: &mut sqlx::SqliteConnection,
    run_id: &str,
    session_id: &str,
    agent_id: &str,
    task: &str,
    result: Option<&str>,
    status: &str,
    error: Option<&str>,
    signal_ids: &[String],
    now: i64,
) -> Result<(), StorageError> {
    let Some(revision) = convert_reservation_tx(tx, session_id, now).await? else {
        return Err(StorageError::Conflict(
            "background completion lost its reserved slot".to_string(),
        ));
    };
    let event = NewInboxEvent {
        id: uuid::Uuid::new_v4().to_string(),
        root_session_id: session_id.to_string(),
        run_id: Some(run_id.to_string()),
        event_type: AgentEventType::Completion,
        event_key: format!("completion:{run_id}"),
        delivery: AgentEventDelivery::Queue,
        requires_continuation: true,
        severity: if error.is_some() {
            Some("error".to_string())
        } else {
            None
        },
        payload_json: completion_payload(run_id, agent_id, task, result, status, error, signal_ids),
    };
    insert_event_tx(tx, &event, revision, now).await
}

/// Bounded payload for a run completion event.  Carries enough task/result
/// context for the main Agent to report which run finished and what it
/// produced, while the full result stays in `agent_runs.result`.
pub(crate) fn completion_payload(
    run_id: &str,
    agent_id: &str,
    task: &str,
    result: Option<&str>,
    status: &str,
    error: Option<&str>,
    signal_ids: &[String],
) -> String {
    serde_json::json!({
        "run_id": run_id,
        "agent_id": agent_id,
        "task": truncate_utf8(task, 500),
        "result": result.map(|r| truncate_utf8(r, 2_000)),
        "status": status,
        "error": error,
        "signal_ids": signal_ids,
    })
    .to_string()
}

/// Truncate a string to at most `max` UTF-8 characters.
pub(crate) fn truncate_utf8(value: &str, max: usize) -> String {
    if value.chars().count() <= max {
        value.to_string()
    } else {
        let mut truncated: String = value.chars().take(max).collect();
        truncated.push('…');
        truncated
    }
}

/// Default trigger content for a continuation Turn.  Events are rendered as
/// readable summaries (task, agent, result) rather than raw JSON so the main
/// Agent knows exactly which run finished and what it produced.
pub fn build_continuation_trigger(
    events: &[AgentInboxEventRecord],
    now: i64,
) -> crate::bus::ChatMessage {
    let mut content = String::from(
        "后台 Agent 任务已经完成，以下是完成结果。请结合这些结果继续当前对话，向用户呈现最相关的部分；\
         不要重复执行已经完成的工作，也不要复述原始任务描述。",
    );
    for event in events {
        let payload: serde_json::Value =
            serde_json::from_str(&event.payload_json).unwrap_or(serde_json::Value::Null);
        let get = |key: &str| {
            payload
                .get(key)
                .and_then(serde_json::Value::as_str)
                .unwrap_or_default()
                .to_string()
        };
        match event.event_type {
            AgentEventType::Completion => {
                let status = get("status");
                let agent_id = get("agent_id");
                let run_id = get("run_id");
                let task = get("task");
                let result = get("result");
                let error = get("error");
                content.push_str("\n\n## 后台任务完成");
                if !agent_id.is_empty() {
                    content.push_str(&format!("（Agent：{agent_id}）"));
                }
                content.push_str(&format!("，状态：{status}"));
                if !run_id.is_empty() {
                    content.push_str(&format!("，Run ID：{run_id}"));
                }
                content.push('\n');
                if !task.is_empty() {
                    content.push_str(&format!("- 任务：{task}\n"));
                }
                if !result.is_empty() {
                    content.push_str(&format!("- 结果：{result}\n"));
                }
                if !error.is_empty() {
                    content.push_str(&format!("- 错误：{error}\n"));
                }
            }
            AgentEventType::Signal => {
                let severity = get("severity");
                let agent_id = get("agent_id");
                let run_id = get("run_id");
                let summary = get("summary");
                content.push_str("\n\n## 后台信号");
                if !severity.is_empty() {
                    content.push_str(&format!("（严重级别：{severity}）"));
                }
                if !agent_id.is_empty() {
                    content.push_str(&format!("，Agent：{agent_id}"));
                }
                if !run_id.is_empty() {
                    content.push_str(&format!("，Run ID：{run_id}"));
                }
                content.push('\n');
                if !summary.is_empty() {
                    content.push_str(&format!("- 摘要：{summary}\n"));
                }
            }
        }
    }
    let mut message = crate::bus::ChatMessage::user(content);
    message.client_visibility = ClientVisibility::Hidden;
    message.turn_origin = TurnOrigin::AgentContinuation;
    message.timestamp = now;
    message
}

/// Read-only tool set allowed during continuation Turns.  The root Agent may
/// inspect results and read files but cannot write, message, delegate or
/// schedule anything.
pub fn continuation_tool_names() -> &'static [&'static str] {
    &[
        "file_read",
        "file_search",
        "content_search",
        "web_fetch",
        "calculator",
        "agent_task",
    ]
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    async fn create_test_storage() -> (super::super::Storage, TempDir) {
        let dir = tempfile::tempdir().unwrap();
        let db_path = dir.path().join("inbox.db");
        let storage = super::super::Storage::new(&db_path).await.unwrap();
        (storage, dir)
    }

    fn completion_event(session: &str, run_id: &str) -> NewInboxEvent {
        NewInboxEvent {
            id: uuid::Uuid::new_v4().to_string(),
            root_session_id: session.to_string(),
            run_id: Some(run_id.to_string()),
            event_type: AgentEventType::Completion,
            event_key: format!("completion:{run_id}"),
            delivery: AgentEventDelivery::Queue,
            requires_continuation: true,
            severity: None,
            payload_json: serde_json::json!({ "status": "completed" }).to_string(),
        }
    }

    async fn seed_run(storage: &super::super::Storage, run_id: &str, session: &str) {
        use crate::storage::agent_run::{AcceptAgentRequest, AgentRunMode, NewAgentRun};
        storage
            .accept_agent_runs(AcceptAgentRequest {
                runs: vec![NewAgentRun {
                    id: run_id.to_string(),
                    root_session_id: session.to_string(),
                    root_turn_id: None,
                    parent_run_id: None,
                    caller_agent_id: "ROOT".to_string(),
                    caller_scope_id: "turn-1".to_string(),
                    idempotency_key: None,
                    agent_id: "researcher".to_string(),
                    definition_hash: "hash".to_string(),
                    provider_profile: "research".to_string(),
                    provider_name: "test".to_string(),
                    model_id: "test-model".to_string(),
                    mode: AgentRunMode::Foreground,
                    depth: 1,
                    plan_item_id: None,
                    execution_id: format!("exec-{run_id}"),
                    task: "work".to_string(),
                    context_json: None,
                    budget_json: "{}".to_string(),
                    signal_contract_json: None,
                    signal_delivery: None,
                    deadline_at: 1000,
                    runtime_generation: 1,
                    completion_slot_reserved: false,
                }],
                now: 5,
            })
            .await
            .unwrap();
    }

    #[tokio::test]
    async fn reservation_and_completion_never_exceed_capacity() {
        let (storage, _dir) = create_test_storage().await;
        storage
            .ensure_agent_session_state("cli:test:d1", 1)
            .await
            .unwrap();
        seed_run(&storage, "run-1", "cli:test:d1").await;
        seed_run(&storage, "run-2", "cli:test:d1").await;

        // Reserve both slots.
        assert!(
            storage
                .reserve_completion_slots("cli:test:d1", 2, 2, 10)
                .await
                .unwrap()
                .is_some()
        );
        assert!(
            storage
                .reserve_completion_slots("cli:test:d1", 1, 2, 20)
                .await
                .unwrap()
                .is_none()
        );

        // A signal must not displace reserved completions, and a completion
        // event cannot be inserted over the reservation cap either (the
        // terminal-commit path converts a reservation instead).
        let mut event = completion_event("cli:test:d1", "run-1");
        event.event_type = AgentEventType::Signal;
        assert!(
            storage
                .insert_agent_signal(&event, 2, 30)
                .await
                .unwrap()
                .is_none()
        );
        assert!(
            storage
                .insert_agent_signal(&completion_event("cli:test:d1", "run-2"), 2, 40)
                .await
                .unwrap()
                .is_none()
        );
    }

    #[tokio::test]
    async fn claim_release_cycle_preserves_events() {
        let (storage, _dir) = create_test_storage().await;
        seed_run(&storage, "run-1", "cli:test:d1").await;
        let event = completion_event("cli:test:d1", "run-1");
        storage
            .insert_agent_signal(&event, 16, 10)
            .await
            .unwrap()
            .unwrap();

        let lease = storage
            .claim_inbox_batch("cli:test:d1", 100, 60_000, 8, 32 * 1024, None)
            .await
            .unwrap()
            .expect("due event");
        assert_eq!(lease.events.len(), 1);
        assert_eq!(lease.events[0].status, AgentEventStatus::Leased);

        // Wrong token cannot release.
        storage
            .release_inbox_lease(
                "wrong-token",
                std::slice::from_ref(&event.id),
                5000,
                Some("x"),
                110,
            )
            .await
            .unwrap();
        let still_leased = storage
            .get_agent_inbox_event(&event.id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(still_leased.status, AgentEventStatus::Leased);

        storage
            .release_inbox_lease(
                &lease.token,
                std::slice::from_ref(&event.id),
                5000,
                Some("failed"),
                120,
            )
            .await
            .unwrap();
        let pending = storage
            .get_agent_inbox_event(&event.id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(pending.status, AgentEventStatus::Pending);
        assert_eq!(pending.attempt_count, 1);
        assert_eq!(pending.last_error.as_deref(), Some("failed"));
        assert_eq!(pending.next_attempt_at, Some(120 + 5000));
    }

    #[tokio::test]
    async fn batch_claim_respects_size_and_payload_bounds() {
        let (storage, _dir) = create_test_storage().await;
        for index in 0..4 {
            seed_run(&storage, &format!("run-{index}"), "cli:test:d1").await;

            storage
                .insert_agent_signal(
                    &completion_event("cli:test:d1", &format!("run-{index}")),
                    16,
                    10,
                )
                .await
                .unwrap()
                .unwrap();
        }
        let lease = storage
            .claim_inbox_batch("cli:test:d1", 100, 60_000, 2, 32 * 1024, None)
            .await
            .unwrap()
            .expect("due events");
        assert_eq!(lease.events.len(), 2);
        // Remaining events stay pending for the next claim.
        let lease2 = storage
            .claim_inbox_batch("cli:test:d1", 100, 60_000, 2, 32 * 1024, None)
            .await
            .unwrap()
            .expect("remaining events");
        assert_eq!(lease2.events.len(), 2);
    }

    #[tokio::test]
    async fn supersede_reduces_pending_count() {
        let (storage, _dir) = create_test_storage().await;
        seed_run(&storage, "run-1", "cli:test:d1").await;
        let mut event = completion_event("cli:test:d1", "run-1");
        event.event_type = AgentEventType::Signal;
        storage
            .insert_agent_signal(&event, 16, 10)
            .await
            .unwrap()
            .unwrap();

        let count = storage
            .supersede_agent_events("run-1", AgentEventType::Signal, 20)
            .await
            .unwrap();
        assert_eq!(count, 1);
        let record = storage
            .get_agent_inbox_event(&event.id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(record.status, AgentEventStatus::Superseded);
    }

    #[tokio::test]
    async fn continuation_commit_is_atomic_with_event_consumption() {
        use crate::storage::message::MessageMeta;
        use crate::storage::session::SessionMeta;

        let (storage, _dir) = create_test_storage().await;
        sqlx::query(
            "INSERT INTO sessions (id, channel, chat_id, dialog_id, created_at, last_active_at) \
             VALUES ('cli:test:d1', 'cli', 'test', 'd1', 1, 1)",
        )
        .execute(storage.pool())
        .await
        .unwrap();
        seed_run(&storage, "run-1", "cli:test:d1").await;
        storage
            .insert_agent_signal(&completion_event("cli:test:d1", "run-1"), 16, 10)
            .await
            .unwrap()
            .unwrap();
        let lease = storage
            .claim_inbox_batch("cli:test:d1", 100, 60_000, 8, 32 * 1024, None)
            .await
            .unwrap()
            .unwrap();

        let session_meta = SessionMeta {
            id: "cli:test:d1".to_string(),
            channel: "cli".to_string(),
            chat_id: "test".to_string(),
            dialog_id: "d1".to_string(),
            title: "test".to_string(),
            created_at: 1,
            last_active_at: 200,
            message_count: 1,
            routing_info: None,
            archived_at: None,
            deleted_at: None,
            last_consolidated_at: None,
            last_compressed_message_at: None,
        };
        let trigger = MessageMeta {
            id: "trigger-1".to_string(),
            session_id: "cli:test:d1".to_string(),
            seq: 1,
            role: "user".to_string(),
            content: "hidden trigger".to_string(),
            reasoning_content: None,
            provider_state: None,
            turn_id: Some("turn-1".to_string()),
            iteration: None,
            completion_status: crate::bus::CompletionStatus::Completed,
            client_visibility: crate::bus::ClientVisibility::Hidden,
            turn_origin: crate::bus::TurnOrigin::AgentContinuation,
            media_refs: None,
            tool_call_id: None,
            tool_name: None,
            tool_calls: None,
            source: None,
            created_at: 200,
        };
        let assistant = MessageMeta {
            id: "assistant-1".to_string(),
            session_id: "cli:test:d1".to_string(),
            seq: 2,
            role: "assistant".to_string(),
            content: "task finished".to_string(),
            reasoning_content: None,
            provider_state: None,
            turn_id: Some("turn-1".to_string()),
            iteration: Some(1),
            completion_status: crate::bus::CompletionStatus::Completed,
            client_visibility: crate::bus::ClientVisibility::Visible,
            turn_origin: crate::bus::TurnOrigin::AgentContinuation,
            media_refs: None,
            tool_call_id: None,
            tool_name: None,
            tool_calls: None,
            source: None,
            created_at: 200,
        };

        let committed = storage
            .commit_continuation_turn(
                "cli:test:d1",
                &trigger,
                &[assistant],
                &session_meta,
                None,
                &lease.token,
                &[lease.events[0].id.clone()],
                200,
            )
            .await
            .unwrap();
        assert_eq!(committed.len(), 1);

        // Messages persisted; hidden trigger is invisible to client queries.
        let replay = storage.load_messages("cli:test:d1", 0).await.unwrap();
        assert_eq!(replay.len(), 2);
        let visible = storage
            .load_recent_session_messages("cli:test:d1", 100)
            .await
            .unwrap();
        assert!(
            visible.iter().all(|message| {
                message.client_visibility == crate::bus::ClientVisibility::Visible
            })
        );
        assert!(visible.iter().any(|message| message.id == "assistant-1"));
        assert!(visible.iter().all(|message| message.id != "trigger-1"));
        assert!(
            visible
                .iter()
                .all(|message| message.turn_origin == crate::bus::TurnOrigin::AgentContinuation)
        );

        // The event was consumed; the lease token now owns nothing.
        let record = storage
            .get_agent_inbox_event(&lease.events[0].id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(record.status, AgentEventStatus::Consumed);
        assert_eq!(record.consumed_at, Some(200));
        let state: i64 = sqlx::query_scalar(
            "SELECT pending_event_count FROM agent_session_state WHERE root_session_id = 'cli:test:d1'",
        )
        .fetch_one(storage.pool())
        .await
        .unwrap();
        assert_eq!(state, 0);
    }

    #[tokio::test]
    async fn dead_letter_leased_events_reduces_pending_and_requires_token() {
        let (storage, _dir) = create_test_storage().await;
        seed_run(&storage, "run-1", "cli:test:d1").await;
        storage
            .insert_agent_signal(&completion_event("cli:test:d1", "run-1"), 16, 10)
            .await
            .unwrap()
            .unwrap();
        let lease = storage
            .claim_inbox_batch("cli:test:d1", 100, 60_000, 8, 32 * 1024, None)
            .await
            .unwrap()
            .unwrap();

        // Wrong token cannot dead-letter.
        assert_eq!(
            storage
                .dead_letter_leased_events("wrong", &[lease.events[0].id.clone()], "boom", 110)
                .await
                .unwrap(),
            0
        );
        assert_eq!(
            storage
                .dead_letter_leased_events(&lease.token, &[lease.events[0].id.clone()], "boom", 120)
                .await
                .unwrap(),
            1
        );
        let record = storage
            .get_agent_inbox_event(&lease.events[0].id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(record.status, AgentEventStatus::DeadLetter);
        assert_eq!(record.dead_lettered_at, Some(120));
        let state: i64 = sqlx::query_scalar(
            "SELECT pending_event_count FROM agent_session_state WHERE root_session_id = 'cli:test:d1'",
        )
        .fetch_one(storage.pool())
        .await
        .unwrap();
        assert_eq!(state, 0);
    }

    #[tokio::test]
    async fn next_pending_due_at_tracks_retry_backoff() {
        let (storage, _dir) = create_test_storage().await;
        seed_run(&storage, "run-1", "cli:test:d1").await;
        storage
            .insert_agent_signal(&completion_event("cli:test:d1", "run-1"), 16, 10)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(
            storage.next_pending_due_at("cli:test:d1").await.unwrap(),
            Some(10)
        );
        let lease = storage
            .claim_inbox_batch("cli:test:d1", 100, 60_000, 8, 32 * 1024, None)
            .await
            .unwrap()
            .unwrap();
        // Leased events are not pending anymore.
        assert_eq!(
            storage.next_pending_due_at("cli:test:d1").await.unwrap(),
            None
        );
        storage
            .release_inbox_lease(
                &lease.token,
                &[lease.events[0].id.clone()],
                5000,
                Some("x"),
                200,
            )
            .await
            .unwrap();
        assert_eq!(
            storage.next_pending_due_at("cli:test:d1").await.unwrap(),
            Some(200 + 5000)
        );
    }

    #[tokio::test]
    async fn recovery_interrupts_old_generation_and_expires_leases() {
        use crate::storage::agent_run::{AcceptAgentRequest, AgentRunMode, NewAgentRun};

        let (storage, _dir) = create_test_storage().await;
        // A running old-generation background run with a reserved slot.
        let run = NewAgentRun {
            id: "run-1".to_string(),
            root_session_id: "cli:test:d1".to_string(),
            root_turn_id: None,
            parent_run_id: None,
            caller_agent_id: "ROOT".to_string(),
            caller_scope_id: "turn-1".to_string(),
            idempotency_key: None,
            agent_id: "researcher".to_string(),
            definition_hash: "hash".to_string(),
            provider_profile: "research".to_string(),
            provider_name: "test".to_string(),
            model_id: "test-model".to_string(),
            mode: AgentRunMode::Background,
            depth: 1,
            plan_item_id: None,
            execution_id: "exec-1".to_string(),
            task: "work".to_string(),
            context_json: None,
            budget_json: "{}".to_string(),
            signal_contract_json: None,
            signal_delivery: None,
            deadline_at: 1000,
            runtime_generation: 1,
            completion_slot_reserved: false,
        };
        storage
            .accept_agent_runs(AcceptAgentRequest {
                runs: vec![run],
                now: 10,
            })
            .await
            .unwrap();
        storage
            .reserve_completion_slots("cli:test:d1", 1, 16, 10)
            .await
            .unwrap();
        storage
            .mark_agent_run_running("run-1", "exec-1", 10)
            .await
            .unwrap();
        // Reservation is now reflected on the run row.
        sqlx::query("UPDATE agent_runs SET completion_slot_reserved = 1 WHERE id = 'run-1'")
            .execute(storage.pool())
            .await
            .unwrap();

        // An old lease that expires during recovery.
        seed_run(&storage, "run-x", "cli:test:d1").await;
        let event = completion_event("cli:test:d1", "run-x");
        storage
            .insert_agent_signal(&event, 16, 10)
            .await
            .unwrap()
            .unwrap();
        let lease = storage
            .claim_inbox_batch("cli:test:d1", 100, 10, 8, 32 * 1024, None)
            .await
            .unwrap()
            .unwrap();

        let report = storage.recover_agent_state(2, 200, 8, 5000).await.unwrap();
        // run-1 (running) and run-x (queued) both belong to generation 1 and
        // are interrupted; only run-1 held a reserved completion slot.
        assert_eq!(report.interrupted_runs, 2);
        assert_eq!(report.completion_events_generated, 1);
        assert_eq!(report.leases_expired, 1);

        let run = storage.get_agent_run("run-1").await.unwrap().unwrap();
        assert_eq!(
            run.status,
            crate::storage::agent_run::AgentRunStatus::Interrupted
        );

        // The expired lease returned to pending with a backoff.
        let record = storage
            .get_agent_inbox_event(&event.id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(record.status, AgentEventStatus::Pending);
        assert_eq!(record.attempt_count, 2);
        assert!(record.lease_token.is_none());
        drop(lease);
    }

    fn signal_event(session: &str, run_id: &str, dedupe_key: &str, window: i64) -> NewInboxEvent {
        NewInboxEvent {
            id: uuid::Uuid::new_v4().to_string(),
            root_session_id: session.to_string(),
            run_id: Some(run_id.to_string()),
            event_type: AgentEventType::Signal,
            event_key: format!("signal:{dedupe_key}:{window}"),
            delivery: AgentEventDelivery::Steer,
            requires_continuation: true,
            severity: Some("warning".to_string()),
            payload_json: serde_json::json!({ "kind": "signal", "summary": "s" }).to_string(),
        }
    }

    #[tokio::test]
    async fn steer_claim_only_claims_steer_events() {
        let (storage, _dir) = create_test_storage().await;
        seed_run(&storage, "run-1", "cli:test:d1").await;
        seed_run(&storage, "run-2", "cli:test:d1").await;
        let mut queue_event = completion_event("cli:test:d1", "run-1");
        queue_event.delivery = AgentEventDelivery::Queue;
        storage
            .insert_agent_signal(&queue_event, 16, 10)
            .await
            .unwrap()
            .unwrap();
        storage
            .insert_agent_signal(&signal_event("cli:test:d1", "run-2", "k", 0), 16, 10)
            .await
            .unwrap()
            .unwrap();

        // The steer lane claims only steer events; the queue lane claims all.
        let steer_lease = storage
            .claim_inbox_batch(
                "cli:test:d1",
                100,
                60_000,
                8,
                32 * 1024,
                Some(AgentEventDelivery::Steer),
            )
            .await
            .unwrap()
            .unwrap();
        assert_eq!(steer_lease.events.len(), 1);
        assert_eq!(steer_lease.events[0].event_type, AgentEventType::Signal);
        let all_lease = storage
            .claim_inbox_batch("cli:test:d1", 100, 60_000, 8, 32 * 1024, None)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(all_lease.events.len(), 1);
        assert_eq!(all_lease.events[0].event_type, AgentEventType::Completion);
    }

    #[tokio::test]
    async fn signal_dedupe_collapses_same_window_and_reemits_after_cooldown() {
        let (storage, _dir) = create_test_storage().await;
        seed_run(&storage, "run-1", "cli:test:d1").await;
        let first = signal_event("cli:test:d1", "run-1", "svc-a:err", 7);
        let (record, deduplicated) = storage
            .insert_agent_signal(&first, 16, 10)
            .await
            .unwrap()
            .unwrap();
        assert!(!deduplicated);
        let first_id = record.id.clone();

        // Same key inside the cooldown window returns the same event.
        let second = signal_event("cli:test:d1", "run-1", "svc-a:err", 7);
        let (record, deduplicated) = storage
            .insert_agent_signal(&second, 16, 11)
            .await
            .unwrap()
            .unwrap();
        assert!(deduplicated);
        assert_eq!(record.id, first_id);

        // A new cooldown window emits a fresh event.
        let third = signal_event("cli:test:d1", "run-1", "svc-a:err", 8);
        let (record, deduplicated) = storage
            .insert_agent_signal(&third, 16, 12)
            .await
            .unwrap()
            .unwrap();
        assert!(!deduplicated);
        assert_ne!(record.id, first_id);

        // Only one event occupies pending capacity.
        let state: (i64,) = sqlx::query_as(
            "SELECT pending_event_count FROM agent_session_state WHERE root_session_id = 'cli:test:d1'",
        )
        .fetch_one(storage.pool())
        .await
        .unwrap();
        assert_eq!(state.0, 2);
    }

    #[tokio::test]
    async fn steer_admit_then_consume_at_user_turn_commit() {
        use crate::storage::message::MessageMeta;
        use crate::storage::session::SessionMeta;

        let (storage, _dir) = create_test_storage().await;
        sqlx::query(
            "INSERT INTO sessions (id, channel, chat_id, dialog_id, created_at, last_active_at) \
             VALUES ('cli:test:d1', 'cli', 'test', 'd1', 1, 1)",
        )
        .execute(storage.pool())
        .await
        .unwrap();
        seed_run(&storage, "run-1", "cli:test:d1").await;
        storage
            .insert_agent_signal(&signal_event("cli:test:d1", "run-1", "k", 0), 16, 10)
            .await
            .unwrap()
            .unwrap();
        let lease = storage
            .claim_inbox_batch(
                "cli:test:d1",
                100,
                60_000,
                8,
                32 * 1024,
                Some(AgentEventDelivery::Steer),
            )
            .await
            .unwrap()
            .unwrap();
        let event_id = lease.events[0].id.clone();

        // Wrong token cannot admit; the turn id is recorded (not a guard).
        assert!(
            !storage
                .admit_inbox_event(&event_id, "wrong-token", "turn-1", 110)
                .await
                .unwrap()
        );
        assert!(
            storage
                .admit_inbox_event(&event_id, &lease.token, "turn-1", 110)
                .await
                .unwrap()
        );
        let admitted = storage
            .get_agent_inbox_event(&event_id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(admitted.status, AgentEventStatus::Admitted);
        assert_eq!(admitted.admitted_turn_id.as_deref(), Some("turn-1"));

        // The Turn commit consumes the admitted steer event atomically.
        let session_meta = SessionMeta {
            id: "cli:test:d1".to_string(),
            channel: "cli".to_string(),
            chat_id: "test".to_string(),
            dialog_id: "d1".to_string(),
            title: "test".to_string(),
            created_at: 1,
            last_active_at: 200,
            message_count: 1,
            routing_info: None,
            archived_at: None,
            deleted_at: None,
            last_consolidated_at: None,
            last_compressed_message_at: None,
        };
        let user = MessageMeta {
            id: "user-1".to_string(),
            session_id: "cli:test:d1".to_string(),
            seq: 1,
            role: "user".to_string(),
            content: "steer envelope".to_string(),
            reasoning_content: None,
            provider_state: None,
            turn_id: Some("turn-1".to_string()),
            iteration: Some(1),
            completion_status: crate::bus::CompletionStatus::Completed,
            client_visibility: crate::bus::ClientVisibility::Hidden,
            turn_origin: crate::bus::TurnOrigin::User,
            media_refs: None,
            tool_call_id: None,
            tool_name: None,
            tool_calls: None,
            source: None,
            created_at: 200,
        };
        let steer = SteerConsumption {
            event_ids: vec![event_id.clone()],
            admitted_turn_id: "turn-1".to_string(),
        };
        storage
            .persist_turn_batch_with_steer_consumption(
                "cli:test:d1",
                &[user],
                &session_meta,
                &crate::storage::TurnUsageRecord {
                    session_id: "cli:test:d1".to_string(),
                    turn_id: "turn-1".to_string(),
                    provider: "test".to_string(),
                    model: "test-model".to_string(),
                    usage: crate::providers::Usage::default(),
                    last_prompt_tokens: 0,
                    created_at: 200,
                },
                &steer,
            )
            .await
            .unwrap();

        let record = storage
            .get_agent_inbox_event(&event_id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(record.status, AgentEventStatus::Consumed);
        let state: (i64, i64) = sqlx::query_as(
            "SELECT pending_event_count, revision FROM agent_session_state \
             WHERE root_session_id = 'cli:test:d1'",
        )
        .fetch_one(storage.pool())
        .await
        .unwrap();
        assert_eq!(state.0, 0);
        assert!(state.1 >= 2);
    }

    #[tokio::test]
    async fn admitted_steer_event_is_released_back_to_pending() {
        let (storage, _dir) = create_test_storage().await;
        seed_run(&storage, "run-1", "cli:test:d1").await;
        storage
            .insert_agent_signal(&signal_event("cli:test:d1", "run-1", "k", 0), 16, 10)
            .await
            .unwrap()
            .unwrap();
        let lease = storage
            .claim_inbox_batch(
                "cli:test:d1",
                100,
                60_000,
                8,
                32 * 1024,
                Some(AgentEventDelivery::Steer),
            )
            .await
            .unwrap()
            .unwrap();
        let event_id = lease.events[0].id.clone();
        storage
            .admit_inbox_event(&event_id, &lease.token, "turn-1", 110)
            .await
            .unwrap();

        // `/stop`-style release returns the admitted event to pending.
        storage
            .release_inbox_lease(
                &lease.token,
                std::slice::from_ref(&event_id),
                0,
                Some("stopped"),
                120,
            )
            .await
            .unwrap();
        let record = storage
            .get_agent_inbox_event(&event_id)
            .await
            .unwrap()
            .unwrap();
        assert_eq!(record.status, AgentEventStatus::Pending);
        assert_eq!(record.next_attempt_at, Some(120));
    }

    #[test]
    fn completion_trigger_renders_task_agent_and_result() {
        let event = AgentInboxEventRecord {
            id: "evt-1".to_string(),
            root_session_id: "cli:test:d1".to_string(),
            run_id: Some("run-1".to_string()),
            event_type: AgentEventType::Completion,
            event_key: "completion:run-1".to_string(),
            delivery: AgentEventDelivery::Queue,
            requires_continuation: true,
            severity: None,
            payload_json: completion_payload(
                "run-1",
                "researcher",
                "在 bash 中执行 sleep 5 并返回随机数",
                Some("随机数 19471"),
                "completed",
                None,
                &[],
            ),
            status: AgentEventStatus::Pending,
            attempt_count: 0,
            lease_token: None,
            lease_until: None,
            next_attempt_at: None,
            admitted_turn_id: None,
            last_error: None,
            revision: 1,
            created_at: 1,
            consumed_at: None,
            superseded_at: None,
            dead_lettered_at: None,
            fallback_notified_at: None,
            fallback_suppressed_reason: None,
        };
        let trigger = build_continuation_trigger(&[event], 1);
        assert_eq!(
            trigger.client_visibility,
            crate::bus::ClientVisibility::Hidden
        );
        assert!(trigger.content.contains("researcher"));
        assert!(trigger.content.contains("run-1"));
        assert!(trigger.content.contains("在 bash 中执行 sleep 5"));
        assert!(trigger.content.contains("随机数 19471"));
        assert!(
            !trigger.content.contains("\"status\""),
            "raw JSON must not be injected"
        );
    }
}
