//! JSON-RPC 2.0 protocol types for CAR client-server communication.
//!
//! The protocol is bidirectional over WebSocket:
//! - Client → Server: session.init, tools.register, proposal.submit, verify
//! - Server → Client: tools.execute (callback for tool execution)
//! - Server → Client: execution.event (notifications)

/// Wire protocol version for the daemon JSON-RPC protocol. Bump ONLY on a
/// backward-incompatible change to the request/response shapes or method
/// semantics (NOT on every release — this is independent of the package
/// semver). Client and server exchange this in the `server.handshake` RPC so
/// version drift FAILS LOUD with a clear error instead of silently
/// misbehaving or hanging.
pub const PROTOCOL_VERSION: u32 = 1;

pub mod daemon;

/// Compute a deterministic, content-derived run id (EPIC B / B7).
///
/// `runs.start` already treats a caller-supplied `idempotency_key` as the
/// run id, so "same key → same run". This is the canonical way to *derive*
/// that key from the run's content, so two independent devices (or a
/// retried / replayed start) that issue the same logical run compute the
/// **same** id without coordinating — the prerequisite for the multi-device
/// idempotency keys and execution-lease fencing in B5.
///
/// The id is `run-<hex>` where `<hex>` is the first 32 hex chars of
/// `SHA-256(agent_id ‖ "\x1f" ‖ intent ‖ "\x1f" ‖ salt)`. `salt`
/// distinguishes otherwise-identical logical runs (e.g. a date bucket, a
/// scheduler occurrence id, or a user-supplied nonce); pass `""` when the
/// `(agent_id, intent)` pair alone identifies the run. Pure and stable
/// across builds/platforms — pass the result as `runs.start`'s
/// `idempotency_key`.
pub fn deterministic_run_id(agent_id: &str, intent: &str, salt: &str) -> String {
    use sha2::{Digest, Sha256};
    let mut hasher = Sha256::new();
    hasher.update(agent_id.as_bytes());
    hasher.update(b"\x1f");
    hasher.update(intent.as_bytes());
    hasher.update(b"\x1f");
    hasher.update(salt.as_bytes());
    let digest = hasher.finalize();
    let hex: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
    format!("run-{hex}")
}

#[cfg(test)]
mod run_id_tests {
    use super::deterministic_run_id;

    #[test]
    fn same_inputs_same_id() {
        let a = deterministic_run_id("agent-1", "summarize inbox", "2026-06-30");
        let b = deterministic_run_id("agent-1", "summarize inbox", "2026-06-30");
        assert_eq!(a, b, "deterministic: identical inputs → identical id");
        assert!(a.starts_with("run-"));
        assert_eq!(a.len(), 4 + 32);
    }

    #[test]
    fn distinct_inputs_distinct_ids() {
        let base = deterministic_run_id("agent-1", "intent", "s");
        assert_ne!(base, deterministic_run_id("agent-2", "intent", "s"));
        assert_ne!(base, deterministic_run_id("agent-1", "other", "s"));
        assert_ne!(base, deterministic_run_id("agent-1", "intent", "s2"));
    }

    #[test]
    fn no_field_separator_collision() {
        // The 0x1f separator prevents ("ab","c") colliding with ("a","bc").
        assert_ne!(
            deterministic_run_id("ab", "c", ""),
            deterministic_run_id("a", "bc", "")
        );
    }
}

use car_ir::ActionProposal;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;

/// Tool definition sent by client during registration.
///
/// Mirrors `car_ir::ToolSchema` over the wire so the validator,
/// caching, and rate-limiting layers see the same fields the in-process
/// engine does. New optional fields are added with serde defaults so
/// pre-v0.5.x clients (which only sent `name` / `description` /
/// `parameters`) still parse cleanly.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
    pub name: String,
    #[serde(default)]
    pub description: String,
    /// JSON Schema for parameters. Empty object = schemaless (legacy
    /// behavior — validator skips type checks).
    #[serde(default)]
    pub parameters: Value,
    /// JSON Schema for return value (optional).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub returns: Option<Value>,
    /// Marks the tool as safe to cache/retry.
    #[serde(default)]
    pub idempotent: bool,
    /// If set, results are cached with this TTL in seconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cache_ttl_secs: Option<u64>,
    /// If set, rate-limited to this many calls per interval.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rate_limit: Option<ToolRateLimit>,
}

/// Mirror of `car_ir::ToolRateLimit` over the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolRateLimit {
    pub max_calls: u32,
    pub interval_secs: f64,
}

// --- Client → Server requests ---

/// Initialize a session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInitRequest {
    pub client_id: String,
    #[serde(default)]
    pub tools: Vec<ToolDefinition>,
    #[serde(default)]
    pub policies: Vec<PolicyDefinition>,
}

/// Policy definition from client.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyDefinition {
    pub name: String,
    pub rule: String, // deny_tool, deny_tool_param, require_state, etc.
    #[serde(default)]
    pub target: String,
    #[serde(default)]
    pub key: String,
    #[serde(default)]
    pub value: Value,
    #[serde(default)]
    pub pattern: String,
}

/// Submit a proposal for execution.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposalSubmitRequest {
    pub proposal: ActionProposal,
}

/// Verify a proposal without executing.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyRequest {
    pub proposal: ActionProposal,
    #[serde(default)]
    pub initial_state: HashMap<String, Value>,
}

// --- Server → Client callbacks ---

/// Server asks client to execute a tool.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolExecuteRequest {
    pub action_id: String,
    pub tool: String,
    pub parameters: Value,
    #[serde(default)]
    pub timeout_ms: Option<u64>,
    #[serde(default)]
    pub attempt: u32,
    /// Daemon-side callback-routing id (the JSON-RPC `id` of this
    /// `tools.execute` request, e.g. `"cb-1"`), surfaced into the params so
    /// the host can key a per-call abort registry on it. When this call is
    /// reaped (the daemon's callback wait expires), the daemon emits a
    /// `tools.cancel` notification carrying the SAME `request_id` so the host
    /// kills the in-flight child instead of orphaning it (Parslee-ai/car#264).
    ///
    /// **Correlate by `request_id`, not `action_id`** — `action_id` is empty
    /// for legacy `execute()` callers and is not unique across concurrent or
    /// retried attempts. `#[serde(default)]` so pre-#264 hosts still parse the
    /// payload (they just won't get the cancel correlation key).
    #[serde(default)]
    pub request_id: String,
}

/// Fire-and-forget `tools.cancel` notification (Parslee-ai/car#264).
///
/// Emitted server → client when a `tools.execute` callback is reaped (the
/// daemon's per-call wait expired) so the host can abort the in-flight child
/// (e.g. a `claude -p` / `codex exec` driven by `drive_cli`) instead of leaving
/// it orphaned. A notification (no `id`, no response expected): the daemon has
/// already given up on the call and is not waiting on the host's acknowledgment.
///
/// Correlation is by `request_id` (the `tools.execute` routing id), NOT
/// `action_id` — see [`ToolExecuteRequest::request_id`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCancelRequest {
    /// The reaped call's routing id — matches the `request_id` the host saw on
    /// the originating `tools.execute`.
    pub request_id: String,
    /// The originating proposal `Action.id`, for host-side logging/telemetry.
    /// May be empty (legacy `execute()` callers don't carry one).
    #[serde(default)]
    pub action_id: String,
    /// Why the call was cancelled — currently always a callback-timeout reason
    /// string. Advisory; the host should abort regardless of the reason.
    #[serde(default)]
    pub reason: String,
}

/// Client returns tool execution result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolExecuteResponse {
    pub action_id: String,
    #[serde(default)]
    pub output: Option<Value>,
    #[serde(default)]
    pub error: Option<String>,
}

// --- Server → Client notifications ---

/// Execution event notification (streaming).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionEvent {
    pub kind: String, // matches EventKind values
    #[serde(default)]
    pub action_id: Option<String>,
    #[serde(default)]
    pub proposal_id: Option<String>,
    #[serde(default)]
    pub data: HashMap<String, Value>,
}

// --- Host UI protocol ---

/// OS-host-visible agent status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HostAgentStatus {
    Idle,
    Running,
    WaitingForApproval,
    Paused,
    Completed,
    Errored,
    Stopped,
}

/// Host-visible display hints for an agent.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct HostAgentDisplay {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub icon: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub accent: Option<String>,
}

/// Agent entry visible to menu bar, tray, or terminal host clients.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostAgent {
    pub id: String,
    pub name: String,
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub capabilities: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub session_id: Option<String>,
    pub status: HostAgentStatus,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub current_task: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pid: Option<u32>,
    #[serde(default)]
    pub display: HostAgentDisplay,
    pub updated_at: DateTime<Utc>,
    #[serde(default)]
    pub metadata: Value,
}

/// Request to register an agent with the OS host surface.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterHostAgentRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    pub name: String,
    #[serde(default)]
    pub kind: String,
    #[serde(default)]
    pub capabilities: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pid: Option<u32>,
    #[serde(default)]
    pub display: HostAgentDisplay,
    #[serde(default)]
    pub metadata: Value,
}

/// Request to update an agent's host-visible status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetHostAgentStatusRequest {
    pub agent_id: String,
    pub status: HostAgentStatus,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub current_task: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub message: Option<String>,
    #[serde(default)]
    pub payload: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HostApprovalStatus {
    Pending,
    Resolved,
}

/// Approval request visible to the OS host surface.
///
/// `client_id` is the WS session that raised the approval. When
/// `Some(x)`, only session `x` may call `host.resolve_approval` on
/// it — added 2026-05 after a security audit found unrestricted
/// resolve let one client approve another's pending request. When
/// `None` the approval is system-raised (the high-risk-method
/// approval gate uses this so the local UI session can resolve
/// approvals raised by *other* sessions' dispatch attempts) and
/// any authenticated session may resolve it.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostApprovalRequest {
    pub id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub client_id: Option<String>,
    pub action: String,
    pub details: Value,
    #[serde(default)]
    pub options: Vec<String>,
    pub status: HostApprovalStatus,
    pub created_at: DateTime<Utc>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resolved_at: Option<DateTime<Utc>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resolution: Option<String>,
}

/// Request to create an approval prompt in the host surface.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateHostApprovalRequest {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
    pub action: String,
    #[serde(default)]
    pub details: Value,
    #[serde(default)]
    pub options: Vec<String>,
    /// When `true`, the approval is created as system-level: it has
    /// no `client_id` owner and any authenticated session may resolve
    /// it. This is the right mode for agent-requested approvals where
    /// "the user" (via CarHost or `car-host approve`) is the resolver,
    /// not the requesting agent itself. The previous default (always
    /// session-owned by the requester) locked the approval to the
    /// agent's WS connection, which broke as soon as the agent
    /// reconnected — the new session got a fresh client_id and could
    /// no longer resolve its own pending approval, AND CarHost (a
    /// different session) couldn't either.
    ///
    /// Defaults to `false` for backward compatibility: existing
    /// callers that don't set this field keep the strict per-session
    /// ownership semantics.
    #[serde(default)]
    pub system_level: bool,
}

/// Request to resolve an approval.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolveHostApprovalRequest {
    pub approval_id: String,
    pub resolution: String,
}

// --- iMessage approval-transport config surface (`messaging.*`) ---
//
// The host/local-auth-gated config channel for the iMessage approval
// transport (Unit 3). These are the ONLY allowlist/config-mutation path
// in the system; the daemon's WS handlers reject any caller that is not
// `session.is_host` or presenting the per-launch local auth token. An
// inbound iMessage carries neither, so it can never mutate config.

/// Result of `messaging.config.get` / `messaging.config.set` — the
/// current (or post-mutation) view of the transport config. The active
/// pairing code is intentionally NOT echoed here (it is surfaced only via
/// `messaging.pairing.status`, mirroring the local-UI-rooted invariant).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessagingConfigView {
    /// Which channel this view describes (stable string key: `"imessage"` /
    /// `"slack"`). Echoed so a per-channel round-trip can confirm WHICH channel
    /// the flags belong to. Defaults to `"imessage"` for the back-compat
    /// surface.
    #[serde(default = "default_channel_key")]
    pub channel: String,
    /// Master opt-in flag (default `false`).
    pub enabled: bool,
    /// Approver handles permitted to resolve approvals over this channel.
    pub allowlisted_handles: Vec<String>,
    /// Whether a pairing is currently in flight (a code has been minted
    /// and not yet consumed). The code value itself is not exposed here.
    pub pairing_active: bool,
}

/// Back-compat default for `MessagingConfigView::channel` — iMessage.
fn default_channel_key() -> String {
    "imessage".to_string()
}

/// Params for `messaging.config.set`. All fields optional — only the
/// supplied fields mutate (a `null`/absent field leaves that part of the
/// config unchanged). `add_handles` / `remove_handles` apply after
/// `allowlisted_handles` when both are present.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessagingConfigSetRequest {
    /// Which channel this mutation targets (stable string key: `"imessage"` /
    /// `"slack"`). **Absent ⇒ iMessage** — back-compat for the #403 surface and
    /// bindings, which have no `channel` field. (The full FFI/doc parity for the
    /// explicit `channel` field — `.d.ts`/`.pyi`/websocket-protocol — lands in
    /// Unit 6; the server-side optional field is added here so the per-channel
    /// WS round-trip works. The wire value stays a plain string tagged by
    /// channel — no typed identity struct across FFI.)
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub channel: Option<String>,
    /// When present, set the master opt-in flag.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
    /// When present, REPLACE the entire allowlist with these handles.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub allowlisted_handles: Option<Vec<String>>,
    /// When present, add each handle to the allowlist (idempotent).
    #[serde(default)]
    pub add_handles: Vec<String>,
    /// When present, remove each handle from the allowlist (idempotent).
    #[serde(default)]
    pub remove_handles: Vec<String>,
    /// Slack bot token (`xoxb-`) to provision. When BOTH `bot_token` and
    /// `app_token` are present (Slack channel only), the daemon writes them to
    /// the OS keychain (MC-9) and persists only a keychain *reference* into the
    /// config — the bearer value never lands in `messaging.json` nor echoes back
    /// in the response. This is a host-gated, write-only provisioning input; an
    /// inbound message cannot reach this surface (MC-6). Absent ⇒ no token
    /// change (back-compat for the enable/allowlist-only callers).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub bot_token: Option<String>,
    /// Slack app-level token (`xapp-`) to provision. See [`Self::bot_token`] —
    /// both must be present to trigger provisioning.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub app_token: Option<String>,
    /// Slack post-channel id (`C0123…` / `D024…`) — the conversation the
    /// outbound approval prompt posts into (Slack channel only). Unlike the
    /// tokens this is CONFIGURATION, not a secret: the daemon persists it IN
    /// `messaging.json` (host-gated), never the keychain. Set on the same
    /// `messaging.config.set { channel: "slack", … }` call as the tokens.
    /// Absent ⇒ no post-channel change (back-compat).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub slack_channel: Option<String>,
}

/// Result of `messaging.pairing.start` — the freshly minted, high-entropy
/// pairing code to display ONLY in the local UI, plus the post-mint view.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessagingPairingStartResponse {
    /// The minted pairing code. Shown only in local UI; the paired device
    /// texts it back to prove control of its handle.
    pub pairing_code: String,
    /// Post-mint config view (`pairing_active` is now `true`).
    pub config: MessagingConfigView,
}

/// Result of `messaging.pairing.status` — whether a pairing is in flight
/// and, when so, the active code (host/local-auth gated read only, so the
/// local UI can re-display the code after a reload).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MessagingPairingStatusResponse {
    /// Whether a pairing code is currently active.
    pub pairing_active: bool,
    /// The active pairing code, when one is in flight. Returned only over
    /// the host/local-auth-gated surface — never over any inbound channel.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pairing_code: Option<String>,
}

/// Result of `messaging.status` — the real runtime liveness of a channel's
/// approval transport, computed daemon-side (U2). The host UI renders a SINGLE
/// readiness state from this object rather than re-deriving "is it on" from
/// scattered permission widgets (the scatter that produced the confusing pane).
///
/// Readiness order (the pane resolves the FIRST failing condition):
/// `enabled` → `watcher_running` → `fda_readable` → `paired` → Ready.
/// `last_send_*` / `last_error` drive "last delivered" + a surfaced error.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessagingStatusView {
    /// Which channel this status describes (`"imessage"` / `"slack"`).
    #[serde(default = "default_channel_key")]
    pub channel: String,
    /// Master opt-in flag for this channel (condition 1).
    pub enabled: bool,
    /// Whether at least one handle is paired/allowlisted (condition 2).
    pub paired: bool,
    /// Whether this channel's watcher loop is currently spawned (condition 3 —
    /// the invisible-restart fix; `true` once U1 has spawned it).
    pub watcher_running: bool,
    /// Whether the daemon can read the Messages database (Full Disk Access —
    /// condition 4). Probed daemon-side (the daemon is the reader). For non-
    /// iMessage channels this is `true` (no chat.db dependency).
    pub fda_readable: bool,
    /// Unix-epoch milliseconds of the most recent recorded send (success OR
    /// failure). `None` until the first send. Drives "Last delivered: <time>".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_send_at_ms: Option<i64>,
    /// Whether the most recent recorded send succeeded. `None` until the first
    /// send; `Some(false)` for a hard error OR a soft `sent:false`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_send_ok: Option<bool>,
    /// The most recent send FAILURE reason (hard error or soft `sent:false`).
    /// `None` when the last send succeeded or none has happened. Surfaced in the
    /// pane so a swallowed failure becomes visible.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_error: Option<String>,
}

/// Result of `messaging.test_send` — the synchronous outcome of the on-demand
/// self-test (U4). `ok:true` means the labeled test message was delivered to
/// the paired handle; `ok:false` carries an actionable `error` (channel off, no
/// paired handle, Automation denied, recipient-not-found). The self-test mints
/// NO approval/pairing mapping and resolves nothing.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessagingTestSendResponse {
    /// Whether the test message was delivered.
    pub ok: bool,
    /// Actionable failure reason when `ok == false`; `None` on success.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

/// Host event emitted to subscribed OS host clients.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostEvent {
    pub id: String,
    pub timestamp: DateTime<Utc>,
    pub kind: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
    pub message: String,
    #[serde(default)]
    pub payload: Value,
}

/// Manifest-lock relationship for the daemon serving this WS.
/// Returned inside [`HostIdentity`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum HostManifestRole {
    /// This daemon holds the exclusive `<manifest>.lock` — agents.*
    /// mutations route through here.
    Owner,
    /// Another `car-server` on the host owns the lock; this daemon
    /// runs in observe-only mode (Parslee-ai/car-releases#44).
    Observer,
    /// No manifest is configured at all — `HOME` unset or the
    /// embedder didn't install one.
    None,
}

/// Daemon-identifying metadata returned inside [`HostSnapshot`].
/// Lets multi-daemon hosts (`car-server install` plus an ad-hoc
/// eval daemon, etc.) tell which daemon they connected to and
/// whether THIS one owns the supervisor lock or is observe-only.
/// Closes the observability gap from Parslee-ai/car-releases#44.
///
/// Stable on the wire across the connection's lifetime — emitted
/// once on subscribe rather than as a periodic event because the
/// fields are immutable for the daemon's lifetime (the manifest
/// role flips only on daemon restart, which would close this WS
/// anyway).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostIdentity {
    /// `CARGO_PKG_VERSION` from the daemon binary at build time.
    pub version: String,
    /// `std::process::id()` of the daemon — operators correlating
    /// log lines + `ps`/`lsof` output need this.
    pub pid: u32,
    /// Absolute path to the lifecycle-agent manifest this daemon
    /// supervises (or observes). `None` when no manifest is
    /// configured (`HOME` unset; embedder didn't install one).
    /// Lossy-encoded on non-UTF-8 paths — operators on path
    /// layouts that round-trip through this field should normalize
    /// upstream.
    pub manifest_path: Option<String>,
    pub manifest_role: HostManifestRole,
    /// Parslee cloud account bound to this daemon, when the local user
    /// has completed `car auth login`. This is advisory identity for
    /// cloud-backed features; the local WS auth token still gates access
    /// to the daemon process.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub parslee: Option<ParsleeIdentity>,
}

/// Parslee cloud identity associated with the local CAR user.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParsleeIdentity {
    pub account_id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub display_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub active_organization: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub organization_name: Option<String>,
}

/// Snapshot returned by `host.subscribe`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HostSnapshot {
    pub subscribed: bool,
    #[serde(default)]
    pub agents: Vec<HostAgent>,
    #[serde(default)]
    pub approvals: Vec<HostApprovalRequest>,
    #[serde(default)]
    pub events: Vec<HostEvent>,
    /// Daemon-identifying metadata — added 2026-05 to surface
    /// observe-only mode (Parslee-ai/car-releases#44) and let
    /// multi-daemon hosts tell which daemon they're talking to.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identity: Option<HostIdentity>,
}

// --- Run lifecycle (agent run tracing, U1) ---
//
// A "run" is one `runs.start` / `runs.complete` bracket around an agent
// loop, identified by a durable `run_id` (a uuid) that is independent of
// the ephemeral, server-assigned WS `client_id`. U1 introduces only the
// run boundary + terminal-outcome carriers; the per-turn `RunTurn` /
// `CliOutcome` / `VerifierVerdict` model lands in U2.
//
// `RunStarted` and `RunEnded` are the two durable run records U1 emits.
// They serialize as tagged JSON (`{ "kind": "started", ... }` /
// `{ "kind": "ended", ... }`) so U2/U3 can extend the record set
// (adding a `Turn` variant) without breaking the on-wire shape.

/// How a run reached its terminal state.
///
/// `Outcome` carries the harness-reported `AgentOutcome` from
/// `runs.complete`. `Incomplete` is written daemon-side when a harness
/// disconnects without ever reporting an outcome (past the short grace
/// window) — R5. It is deliberately distinct from any `OutcomeStatus`
/// so the dashboard can render "the harness vanished" differently from
/// "the agent gave up".
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RunTermination {
    /// The harness called `runs.complete` with a terminal outcome.
    Outcome {
        /// Convenience copy of `outcome.status`, surfaced top-level so
        /// list views can render the terminal banner without parsing
        /// the full `AgentOutcome`.
        status: car_ir::OutcomeStatus,
        outcome: car_ir::AgentOutcome,
    },
    /// The connection dropped mid-run with no `runs.complete` — the
    /// daemon wrote this marker. No `AgentOutcome` is available.
    Incomplete,
}

/// A run began — recorded when `runs.start` mints the `run_id`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStarted {
    pub run_id: String,
    /// The owning agent. Resolved from `session.auth {agent_id}`,
    /// `CAR_AGENT_ID`, or (one-shot fallback) a deterministic id
    /// synthesized from the agent's name.
    pub agent_id: String,
    /// What the agent was asked to do (free text from the harness).
    pub intent: String,
    /// The outcome the agent is steering toward, when supplied.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outcome_description: Option<String>,
    pub started_at: DateTime<Utc>,
}

/// A run reached a terminal state — recorded on `runs.complete` (with a
/// reported outcome) or on a mid-run disconnect (as `Incomplete`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunEnded {
    pub run_id: String,
    pub agent_id: String,
    pub termination: RunTermination,
    pub ended_at: DateTime<Utc>,
}

// --- Per-turn run trace (agent run tracing, U2) ---
//
// A "turn" is one submitted proposal (no inference chain-of-thought
// capture). The recorder (`car-server-core/src/run_trace.rs`) joins the
// submitted proposal's `actions[i]` to the resulting `ActionResult`s by
// `action_id` and emits one `RunTurn` per action, tagged with the
// session's current `run_id`. The recorder is tool-agnostic — it always
// records `tool` / `parameters` / `output` — and applies a thin, optional
// classifier for Bulldozer's `drive_cli` / `check_outcome` tools to fill
// `cli_outcome` / `verifier_verdict`. Those return-shape field names
// (`output_tail` / `exit_code` / `timed_out` / `passed`) are the agent's
// contract, not the runtime's; the classifier keys on them.

/// How a CLI-driving action (e.g. Bulldozer's `drive_cli`) terminated.
///
/// `Exited { code }` is the normal case — the process ran and returned an
/// exit code. `Killed` is a signal death (the tool surfaced a `signal`
/// with no numeric `exit_code`). `Timeout` is the tool's own
/// `timed_out` flag. `KTD7`: this is one of the orthogonal, multi-valued
/// per-turn outcome fields — distinct from the run-level `OutcomeStatus`
/// — so a timed-out drive never gets mis-rendered as a run failure.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CliOutcome {
    /// The process exited with a numeric code (0 = success).
    Exited { code: i64 },
    /// The process was killed by a signal (no numeric exit code).
    Killed,
    /// The tool reported `timed_out = true`.
    Timeout,
}

/// The verifier verdict for a turn — Bulldozer's `check_outcome` result.
///
/// "Verifier" here is the agent's `check_outcome` tool result (its
/// `passed` field), NOT the runtime's static `verifyProposal` gate. A
/// turn with `Fail` is the healthy re-prod case (drove another turn),
/// not a run failure — KTD7 / R11. `NotRun` covers a turn that never
/// reached the verifier (a `drive_cli`-only turn, a timeout, or a
/// policy-rejected action).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VerifierVerdict {
    /// `check_outcome` returned `passed = true`.
    Pass,
    /// `check_outcome` returned `passed = false` (healthy re-prod — amber).
    Fail,
    /// The verifier did not run for this turn.
    NotRun,
}

/// A policy rejection captured on a turn (R2 / R11). When an action is
/// `ActionStatus::Rejected`, the tool body never ran, so `cli_outcome`
/// is forced to `not-run` and the rejection is recorded here.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PolicyRejection {
    /// The rule that fired — the verbatim `ActionResult.error` string
    /// (e.g. `policy 'no-destructive': param 'prompt' matches 'rm -rf'`).
    pub rule: String,
    /// The blocked parameter name, best-effort extracted from the
    /// rejection reason (the `param '<name>'` token a `deny_tool_param`
    /// rejection carries). `None` when the reason has no param token.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub param: Option<String>,
}

/// One captured turn of a run — one action of one submitted proposal.
///
/// The recorder always fills `index` / `prompt` / `tool` / `parameters`
/// / `output` (tool-agnostic). `cli_outcome` / `verifier_verdict` /
/// `policy_rejected` are the thin Bulldozer classifier's enrichment and
/// are `None` / `NotRun` for any other tool. Multi-valued and orthogonal
/// to the run-level `OutcomeStatus` (KTD7).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunTurn {
    /// 0-based position of this action within the run's ordered turn
    /// stream (monotonic across proposals in the run).
    pub index: usize,
    /// The prompt handed to the driven CLI — the action's `prompt`
    /// parameter when present (`drive_cli`). `None` for tools that take
    /// no `prompt`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,
    /// The tool name from the submitted action (`drive_cli`,
    /// `check_outcome`, or any other). `None` for non-`ToolCall` actions.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool: Option<String>,
    /// The full action parameters as submitted — tool-agnostic capture so
    /// non-Bulldozer agents still get a usable trail.
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub parameters: Value,
    /// The tool's returned output value (the `ActionResult.output`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub output: Option<Value>,
    /// The classified CLI outcome for a `drive_cli` turn; `None` for
    /// non-driving tools.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cli_outcome: Option<CliOutcome>,
    /// The verifier verdict — `Pass`/`Fail` from a `check_outcome` turn,
    /// `NotRun` otherwise.
    pub verifier_verdict: VerifierVerdict,
    /// A policy rejection, when this action was `Rejected`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub policy_rejected: Option<PolicyRejection>,
}

/// One durable run record. U1 ships `Started` / `Ended`; U2 adds the
/// per-turn `Turn` variant. Tagged on `record` so adding a variant is
/// forward-compatible on the wire.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "record", rename_all = "snake_case")]
pub enum RunRecord {
    Started(RunStarted),
    Ended(RunEnded),
    Turn(RunTurn),
}

/// `runs.start` request params.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStartRequest {
    /// Owning agent id. Optional on the wire: when absent the daemon
    /// resolves from `session.auth {agent_id}`, then `CAR_AGENT_ID`,
    /// then falls back to a deterministic id synthesized from
    /// `agent_name`. With none of these available, `runs.start` is
    /// rejected.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_id: Option<String>,
    /// Agent display name — the one-shot fallback source for a
    /// deterministic `agent_id` when nothing else resolves.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_name: Option<String>,
    pub intent: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub outcome_description: Option<String>,
    /// Optional caller-supplied run id for an idempotent start. When
    /// present and a run with this id already exists — in-flight or
    /// persisted — `runs.start` returns that run instead of opening a
    /// duplicate, so a retried or replayed occurrence records exactly
    /// once. Absent ⇒ the daemon mints a fresh random `run_id` (the prior
    /// behavior). Pairs with the scheduler's deterministic occurrence ids
    /// (see `docs/proposals/deterministic-run-id.md`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub idempotency_key: Option<String>,
}

/// `runs.start` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunStartResponse {
    pub run_id: String,
    pub agent_id: String,
}

/// `runs.complete` request params.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunCompleteRequest {
    pub run_id: String,
    pub outcome: car_ir::AgentOutcome,
}

/// `runs.complete` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunCompleteResponse {
    pub run_id: String,
    pub ok: bool,
}

/// `runs.record_turns` request params — a WS-only batch append of
/// client-narrated turns to a run the calling connection owns. The agent
/// builds full `RunTurn`s itself (out-of-pipeline capture: its work
/// happens inside its own subprocess, never through `proposal.submit`),
/// then pushes them here in batches. The daemon owns the turn `index`
/// (re-stamped under the `runs` lock — the client's `index` values are
/// ignored), the per-field/per-turn byte caps, the batch-size cap, and
/// the per-run turn ceiling; it appends through the same
/// `record_run_turns` path the proposal recorder uses, so persistence and
/// `runs.trace.event` fanout are identical. Turn content beyond size is
/// intentional pass-through — the daemon validates size and ownership,
/// never semantics.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecordTurnsRequest {
    pub run_id: String,
    /// The batch of turns to append, in order. Each is appended as a
    /// [`RunRecord::Turn`]. On the wire a turn may omit `index` (the daemon
    /// owns it — re-stamped under the `runs` lock; any sent value is
    /// ignored) and `verifier_verdict` (defaults to `NotRun`). Must be
    /// non-empty.
    pub turns: Vec<RunTurn>,
}

/// `runs.record_turns` response.
///
/// On a healthy append `ok` is `true`, `base_index` is the daemon-stamped
/// 0-based position of the FIRST turn in the batch, and `count` is the
/// number appended (the stamped indices are `base_index .. base_index +
/// count`). On a non-fatal rejection (`ok: false`) nothing is appended and
/// `dropped` carries the machine-readable reason the agent treats as
/// "stop sending for this run": `run_not_found` (no such run, or the
/// caller isn't entitled — uniform with the read path's not-found, never
/// an owner oracle), `run_terminal` (the run already reported / was swept
/// terminal), `run_turn_limit` (the per-run turn ceiling was reached), or
/// `turn_too_large` (a turn could not be bounded under the per-turn byte
/// cap even after its free-form fields were replaced — a misbehaving
/// client; the whole batch is dropped, never partially admitted).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunRecordTurnsResponse {
    pub run_id: String,
    /// 0-based index of the first appended turn. `0` when nothing was
    /// appended (`ok: false`).
    pub base_index: usize,
    /// Number of turns appended. `0` when `ok: false`.
    pub count: usize,
    pub ok: bool,
    /// The machine-readable drop reason when `ok` is `false`; omitted on a
    /// healthy append.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dropped: Option<String>,
}

// --- Live run-trace subscription (agent run tracing, U4) ---
//
// `runs.subscribe {run_id}` returns a snapshot of the run's turns so far
// plus a `cursor` (the count of records the snapshot covers), then the
// daemon pushes one `runs.trace.event` notification per record appended
// AFTER that cursor. The snapshot + the subscriber registration happen
// atomically under the same lock the recorder holds when it appends, so
// no record in the snapshot/register window is dropped (gap) or
// double-delivered (dup) — R7. The notification is WS-only (no FFI
// method); a CarHost re-issues `runs.subscribe {run_id}` after a
// reconnect and gap-fills via the cursor (R8). Authorization (R16):
// only the run's owning agent connection or the CarHost host-client may
// subscribe.

/// Coarse live status of a run for the subscribe snapshot and each
/// `runs.trace.event`. Distinct from the run-level `OutcomeStatus`
/// carried inside a terminal `RunTermination::Outcome` — this is the
/// "is the run still open?" signal the live client folds into its view.
/// Mirrors `RunStore::RunStatus` but lives in `car-proto` so the wire
/// shape doesn't depend on the server-core crate.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RunLiveStatus {
    /// No terminal record yet — the run is still being written.
    InProgress,
    /// `runs.complete` reported a terminal `AgentOutcome`.
    Completed,
    /// The harness disconnected without reporting an outcome (R5).
    Incomplete,
}

/// `runs.subscribe` request params.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSubscribeRequest {
    pub run_id: String,
}

/// `runs.subscribe` response — the snapshot-at-cursor the client folds
/// before the live `runs.trace.event` stream starts.
///
/// `turns_so_far` holds the run's ordered `RunTurn` records captured at
/// the moment of subscribe; `cursor = turns_so_far.len()` is the turn
/// boundary the daemon streams strictly after. The `RunStarted` data is
/// already conveyed by `agent_id` + the request's `run_id`, and the
/// run's terminal disposition by `status`, so the snapshot carries turns
/// only — the gap/dup-free contract (R7) is defined over the turn stream.
/// Every `RunRecord` in `turns_so_far` is a `RunRecord::Turn`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunSubscribeResponse {
    pub run_id: String,
    pub agent_id: String,
    /// Ordered `RunRecord::Turn` records captured at subscribe time.
    /// `cursor == turns_so_far.len()`.
    pub turns_so_far: Vec<RunRecord>,
    /// Number of turns the snapshot covers — the daemon pushes only
    /// `Turn` records whose post-append count is strictly greater than
    /// this. `Started`/`Ended` lifecycle events are delivered regardless
    /// of cursor (they don't advance it).
    pub cursor: usize,
    pub status: RunLiveStatus,
}

/// `runs.unsubscribe` request params.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunUnsubscribeRequest {
    pub run_id: String,
}

/// `runs.unsubscribe` response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunUnsubscribeResponse {
    pub run_id: String,
    /// `true` if a subscription for this `(connection, run_id)` existed
    /// and was removed; `false` if there was nothing to remove.
    pub removed: bool,
}

/// One `runs.trace.event` server → client notification (agent run
/// tracing, U4). Pushed to every `(host_client, run_id)` subscriber.
///
/// `record` is the appended `RunRecord`:
/// - `Turn` — emitted after `record_run_turns` appends it to the run's
///   in-memory buffer, under the same lock that holds the snapshot/
///   register window closed (the gap/dup-free contract). `cursor` is the
///   run's turn count immediately AFTER this turn was appended (1-based),
///   so a subscriber at turn-cursor `n` expects the next `Turn` event's
///   `cursor` to be `n + 1` — a mismatch means a gap (re-subscribe, R8).
/// - `Started` — emitted on `runs.start`; a lifecycle marker. `cursor`
///   carries the run's current turn count (0 at start) and does not
///   advance the turn stream.
/// - `Ended` — emitted on `runs.complete` / disconnect-`Incomplete`;
///   carries the final turn count in `cursor` and the terminal `status`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunTraceEvent {
    pub run_id: String,
    pub agent_id: String,
    pub record: RunRecord,
    /// The run's turn count after this record was processed. Advances by
    /// one per `Turn`; unchanged on `Started`/`Ended`.
    pub cursor: usize,
    pub status: RunLiveStatus,
}

// --- Replay read RPCs (agent run tracing, U5) ---
//
// `runs.list {agent_id}` and `runs.get_trace {run_id, cursor?}` are the
// WS-only replay reads CarHost uses to list an agent's runs and fetch a
// completed run's full trace. They read the disk store (`RunStore`), so
// they work after a daemon restart / `client_id` churn — `run_id` /
// `agent_id` are the durable keys. Both are authorization-gated (R16):
// `runs.list` authorizes the caller for `agent_id` first (the param is
// not a transparent key — an unentitled id is rejected, not enumerated);
// `runs.get_trace` resolves the run's owning `agent_id` from disk and
// authorizes against it.
//
// Only the *request* params are typed here. The responses are built
// inline in the handler (mirroring `agents.tail_log`'s `{ lines }` shape)
// because they carry the run-store's `RunSummary` type, which lives in
// `car-server-core` — `car-proto` must not depend on it. The exact JSON
// response shapes are documented in `docs/websocket-protocol.md` and
// asserted in `car-server-core/tests/run_trace_replay.rs`:
//
//   runs.list      → { agent_id, runs: [RunSummary] }   (newest-first)
//   runs.get_trace → { run_id, agent_id, records: [RunRecord], cursor }
//                    or { run_id, not_found: true } for an unknown run.
//
// `RunSummary` = { run_id, agent_id, intent, started_at, ended_at?,
// status, turn_count }; `RunRecord` is the tagged Started/Turn/Ended
// union defined above. The `cursor` echoes the request's `cursor` (0 when
// omitted) — the index `records` begins at, for paged fetches of large
// runs.

/// `runs.list` request params — list an agent's runs newest-first.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunListRequest {
    /// The agent whose runs to list. Authorization-gated (R16): the
    /// caller must own this agent (`session.auth {agent_id}`) or be the
    /// CarHost host-client. Not a transparent key — an unentitled id is
    /// rejected, never enumerated.
    pub agent_id: String,
}

/// `runs.get_trace` request params — fetch a run's full ordered trace.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunGetTraceRequest {
    /// The run to fetch. The owning `agent_id` is resolved from the disk
    /// store and the caller is authorized against it (R16).
    pub run_id: String,
    /// Optional start index into the run's ordered `RunRecord` stream —
    /// the first record returned. Omitted / `0` returns the whole trace;
    /// a non-zero cursor pages a large run from that offset. The response
    /// echoes the applied cursor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cursor: Option<usize>,
}

// --- Response types ---

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInitResponse {
    pub session_id: String,
    pub tools_registered: usize,
    pub policies_registered: usize,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyResponse {
    pub valid: bool,
    pub issues: Vec<VerifyIssueProto>,
    pub simulated_state: HashMap<String, Value>,
    /// Parallelizable execution batches (DAG levels), action IDs.
    /// Defaulted for backward-compatible deserialization of older
    /// daemons that omitted it.
    #[serde(default)]
    pub execution_levels: Vec<Vec<String>>,
    /// Undeclared write conflicts: (action1, action2, key).
    #[serde(default)]
    pub conflicts: Vec<(String, String, String)>,
    /// Evidence bundle: the verifier's declared scope — checks run,
    /// assumptions, untested regions, residual risks, coverage
    /// confidence (survey "Code as Agent Harness" §5.2.2). Carried as
    /// opaque JSON so car-proto stays decoupled from car-verify; shape
    /// mirrors `car_verify::VerificationEvidence`.
    #[serde(default)]
    pub evidence: Value,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyIssueProto {
    pub action_id: String,
    pub severity: String,
    pub message: String,
}

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

    #[test]
    fn tool_definition_roundtrip() {
        let td = ToolDefinition {
            name: "search".to_string(),
            description: "Search the web".to_string(),
            parameters: serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
            returns: None,
            idempotent: false,
            cache_ttl_secs: None,
            rate_limit: None,
        };
        let json = serde_json::to_string(&td).unwrap();
        let rt: ToolDefinition = serde_json::from_str(&json).unwrap();
        assert_eq!(rt.name, "search");
    }

    #[test]
    fn tool_definition_back_compat_pre_v05_clients() {
        // Pre-v0.5 clients only sent these three fields. The new
        // optional fields must default cleanly so the wire stays
        // backward-compatible.
        let legacy = r#"{"name":"read","description":"","parameters":{}}"#;
        let td: ToolDefinition = serde_json::from_str(legacy).unwrap();
        assert_eq!(td.name, "read");
        assert!(td.returns.is_none());
        assert!(!td.idempotent);
        assert!(td.cache_ttl_secs.is_none());
        assert!(td.rate_limit.is_none());
    }

    #[test]
    fn tool_execute_request_roundtrip() {
        let req = ToolExecuteRequest {
            action_id: "a1".to_string(),
            tool: "search".to_string(),
            parameters: serde_json::json!({"query": "rust"}),
            timeout_ms: Some(5000),
            attempt: 1,
            request_id: "cb-7".to_string(),
        };
        let json = serde_json::to_string(&req).unwrap();
        let rt: ToolExecuteRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(rt.tool, "search");
        assert_eq!(rt.timeout_ms, Some(5000));
        assert_eq!(rt.request_id, "cb-7");
    }

    #[test]
    fn tool_execute_request_request_id_defaults_for_pre264_hosts() {
        // Pre-#264 payloads (no request_id) must still parse — the field
        // defaults to empty so an older host gets a usable callback, just
        // without the cancel correlation key.
        let legacy = r#"{"action_id":"a1","tool":"x","parameters":{},"attempt":1}"#;
        let rt: ToolExecuteRequest = serde_json::from_str(legacy).unwrap();
        assert_eq!(rt.request_id, "");
        assert_eq!(rt.tool, "x");
    }

    #[test]
    fn tool_cancel_request_roundtrip() {
        let c = ToolCancelRequest {
            request_id: "cb-3".to_string(),
            action_id: "a2".to_string(),
            reason: "tool 'drive_cli' callback timed out (185s)".to_string(),
        };
        let json = serde_json::to_string(&c).unwrap();
        let rt: ToolCancelRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(rt.request_id, "cb-3");
        assert_eq!(rt.action_id, "a2");
        assert!(rt.reason.contains("timed out"));
        // action_id + reason default when omitted (only request_id required).
        let minimal: ToolCancelRequest = serde_json::from_str(r#"{"request_id":"cb-9"}"#).unwrap();
        assert_eq!(minimal.request_id, "cb-9");
        assert_eq!(minimal.action_id, "");
        assert_eq!(minimal.reason, "");
    }

    #[test]
    fn tool_execute_response_success() {
        let resp = ToolExecuteResponse {
            action_id: "a1".to_string(),
            output: Some(Value::from("results")),
            error: None,
        };
        let json = serde_json::to_string(&resp).unwrap();
        assert!(json.contains("results"));
    }

    #[test]
    fn tool_execute_response_error() {
        let resp = ToolExecuteResponse {
            action_id: "a1".to_string(),
            output: None,
            error: Some("timeout".to_string()),
        };
        let json = serde_json::to_string(&resp).unwrap();
        assert!(json.contains("timeout"));
    }

    #[test]
    fn session_init_request() {
        let req = SessionInitRequest {
            client_id: "client-1".to_string(),
            tools: vec![ToolDefinition {
                name: "read".to_string(),
                description: "Read file".to_string(),
                parameters: serde_json::json!({}),
                returns: None,
                idempotent: false,
                cache_ttl_secs: None,
                rate_limit: None,
            }],
            policies: vec![],
        };
        let json = serde_json::to_string(&req).unwrap();
        let rt: SessionInitRequest = serde_json::from_str(&json).unwrap();
        assert_eq!(rt.tools.len(), 1);
    }

    #[test]
    fn verify_request() {
        let req = VerifyRequest {
            proposal: ActionProposal {
                id: "p1".to_string(),
                source: "test".to_string(),
                actions: vec![],
                timestamp: chrono::Utc::now(),
                context: HashMap::new(),
            },
            initial_state: [("x".to_string(), Value::from(1))].into(),
        };
        let json = serde_json::to_string(&req).unwrap();
        assert!(json.contains("p1"));
    }

    #[test]
    fn run_start_request_resolves_optional_agent_id() {
        // The harness may omit agent_id (daemon resolves it) and may
        // supply agent_name as the one-shot fallback source.
        let wire = r#"{"intent":"ship the feature","agent_name":"Bulldozer"}"#;
        let req: RunStartRequest = serde_json::from_str(wire).unwrap();
        assert_eq!(req.intent, "ship the feature");
        assert_eq!(req.agent_id, None);
        assert_eq!(req.agent_name.as_deref(), Some("Bulldozer"));
        assert_eq!(req.outcome_description, None);
    }

    #[test]
    fn run_record_started_ended_roundtrip() {
        let started = RunRecord::Started(RunStarted {
            run_id: "run-1".to_string(),
            agent_id: "agent-1".to_string(),
            intent: "do the thing".to_string(),
            outcome_description: Some("the thing is done".to_string()),
            started_at: chrono::Utc::now(),
        });
        let json = serde_json::to_string(&started).unwrap();
        // Tagged on `record` so U2 can add a `Turn` variant without
        // breaking the wire.
        assert!(json.contains("\"record\":\"started\""));
        let rt: RunRecord = serde_json::from_str(&json).unwrap();
        match rt {
            RunRecord::Started(s) => assert_eq!(s.run_id, "run-1"),
            other => panic!("expected Started, got {other:?}"),
        }

        let ended = RunRecord::Ended(RunEnded {
            run_id: "run-1".to_string(),
            agent_id: "agent-1".to_string(),
            termination: RunTermination::Outcome {
                status: car_ir::OutcomeStatus::Success,
                outcome: car_ir::AgentOutcome::success("done"),
            },
            ended_at: chrono::Utc::now(),
        });
        let json = serde_json::to_string(&ended).unwrap();
        assert!(json.contains("\"record\":\"ended\""));
        assert!(json.contains("\"kind\":\"outcome\""));
        let rt: RunRecord = serde_json::from_str(&json).unwrap();
        match rt {
            RunRecord::Ended(e) => match e.termination {
                RunTermination::Outcome { status, .. } => {
                    assert_eq!(status, car_ir::OutcomeStatus::Success)
                }
                other => panic!("expected Outcome, got {other:?}"),
            },
            other => panic!("expected Ended, got {other:?}"),
        }
    }

    #[test]
    fn run_termination_incomplete_serializes_distinctly() {
        let term = RunTermination::Incomplete;
        let json = serde_json::to_string(&term).unwrap();
        assert_eq!(json, r#"{"kind":"incomplete"}"#);
    }

    #[test]
    fn cli_outcome_tagged_variants_roundtrip() {
        let exited = CliOutcome::Exited { code: 0 };
        let json = serde_json::to_string(&exited).unwrap();
        assert_eq!(json, r#"{"kind":"exited","code":0}"#);
        assert_eq!(serde_json::from_str::<CliOutcome>(&json).unwrap(), exited);

        assert_eq!(
            serde_json::to_string(&CliOutcome::Killed).unwrap(),
            r#"{"kind":"killed"}"#
        );
        assert_eq!(
            serde_json::to_string(&CliOutcome::Timeout).unwrap(),
            r#"{"kind":"timeout"}"#
        );
        assert_eq!(
            serde_json::from_str::<CliOutcome>(r#"{"kind":"timeout"}"#).unwrap(),
            CliOutcome::Timeout
        );
    }

    #[test]
    fn verifier_verdict_serializes_snake_case() {
        assert_eq!(
            serde_json::to_string(&VerifierVerdict::Pass).unwrap(),
            r#""pass""#
        );
        assert_eq!(
            serde_json::to_string(&VerifierVerdict::Fail).unwrap(),
            r#""fail""#
        );
        assert_eq!(
            serde_json::to_string(&VerifierVerdict::NotRun).unwrap(),
            r#""not_run""#
        );
        assert_eq!(
            serde_json::from_str::<VerifierVerdict>(r#""not_run""#).unwrap(),
            VerifierVerdict::NotRun
        );
    }

    #[test]
    fn policy_rejection_omits_none_param() {
        let pr = PolicyRejection {
            rule: "policy 'x': denied".to_string(),
            param: None,
        };
        let json = serde_json::to_string(&pr).unwrap();
        assert!(
            !json.contains("param"),
            "None param must be omitted: {json}"
        );
        let with_param = PolicyRejection {
            rule: "policy 'x': param 'prompt' matches 'rm -rf'".to_string(),
            param: Some("prompt".to_string()),
        };
        let json = serde_json::to_string(&with_param).unwrap();
        assert!(json.contains("\"param\":\"prompt\""));
        assert_eq!(
            serde_json::from_str::<PolicyRejection>(&json).unwrap(),
            with_param
        );
    }

    #[test]
    fn run_record_turn_variant_roundtrip() {
        // The `turn` variant must serialize under the same `record` tag as
        // Started/Ended so U3/U4 consume one ordered RunRecord stream.
        let turn = RunRecord::Turn(RunTurn {
            index: 0,
            prompt: Some("make the test pass".to_string()),
            tool: Some("drive_cli".to_string()),
            parameters: serde_json::json!({ "cli": "claude", "prompt": "make the test pass" }),
            output: Some(serde_json::json!({ "exit_code": 0, "output_tail": "done" })),
            cli_outcome: Some(CliOutcome::Exited { code: 0 }),
            verifier_verdict: VerifierVerdict::NotRun,
            policy_rejected: None,
        });
        let json = serde_json::to_string(&turn).unwrap();
        assert!(
            json.contains("\"record\":\"turn\""),
            "turn must tag on `record`: {json}"
        );
        match serde_json::from_str::<RunRecord>(&json).unwrap() {
            RunRecord::Turn(t) => {
                assert_eq!(t.index, 0);
                assert_eq!(t.tool.as_deref(), Some("drive_cli"));
                assert_eq!(t.cli_outcome, Some(CliOutcome::Exited { code: 0 }));
                assert_eq!(t.verifier_verdict, VerifierVerdict::NotRun);
            }
            other => panic!("expected Turn, got {other:?}"),
        }
    }

    #[test]
    fn run_turn_minimal_omits_optional_fields() {
        // A generic, non-Bulldozer turn: no prompt, no cli/verifier
        // classification, no rejection — only the always-present fields
        // serialize plus the required verifier_verdict.
        let turn = RunTurn {
            index: 3,
            prompt: None,
            tool: Some("search".to_string()),
            parameters: serde_json::json!({ "query": "rust" }),
            output: Some(Value::from("results")),
            cli_outcome: None,
            verifier_verdict: VerifierVerdict::NotRun,
            policy_rejected: None,
        };
        let json = serde_json::to_string(&turn).unwrap();
        assert!(!json.contains("prompt"));
        assert!(!json.contains("cli_outcome"));
        assert!(!json.contains("policy_rejected"));
        assert!(json.contains("\"verifier_verdict\":\"not_run\""));
        let rt: RunTurn = serde_json::from_str(&json).unwrap();
        assert_eq!(rt, turn);
    }

    #[test]
    fn run_live_status_roundtrip() {
        // snake_case wire form the live subscribe/event share with the
        // store's RunStatus.
        assert_eq!(
            serde_json::to_string(&RunLiveStatus::InProgress).unwrap(),
            "\"in_progress\""
        );
        assert_eq!(
            serde_json::from_str::<RunLiveStatus>("\"completed\"").unwrap(),
            RunLiveStatus::Completed
        );
        assert_eq!(
            serde_json::from_str::<RunLiveStatus>("\"incomplete\"").unwrap(),
            RunLiveStatus::Incomplete
        );
    }

    #[test]
    fn run_trace_event_wraps_record_and_cursor() {
        // The live notification carries the appended record plus the
        // post-append turn cursor and the run's live status.
        let ev = RunTraceEvent {
            run_id: "run-1".to_string(),
            agent_id: "agent-a".to_string(),
            record: RunRecord::Turn(RunTurn {
                index: 4,
                prompt: Some("fix it".to_string()),
                tool: Some("drive_cli".to_string()),
                parameters: serde_json::json!({ "prompt": "fix it" }),
                output: Some(serde_json::json!({ "exit_code": 0 })),
                cli_outcome: Some(CliOutcome::Exited { code: 0 }),
                verifier_verdict: VerifierVerdict::NotRun,
                policy_rejected: None,
            }),
            cursor: 5,
            status: RunLiveStatus::InProgress,
        };
        let json = serde_json::to_string(&ev).unwrap();
        let back: RunTraceEvent = serde_json::from_str(&json).unwrap();
        assert_eq!(back.run_id, "run-1");
        assert_eq!(back.cursor, 5);
        assert_eq!(back.status, RunLiveStatus::InProgress);
        match back.record {
            RunRecord::Turn(t) => assert_eq!(t.index, 4),
            other => panic!("expected Turn, got {other:?}"),
        }
    }

    #[test]
    fn run_subscribe_response_turns_only_snapshot() {
        let resp = RunSubscribeResponse {
            run_id: "run-1".to_string(),
            agent_id: "agent-a".to_string(),
            turns_so_far: vec![RunRecord::Turn(RunTurn {
                index: 0,
                prompt: None,
                tool: Some("drive_cli".to_string()),
                parameters: Value::Null,
                output: None,
                cli_outcome: None,
                verifier_verdict: VerifierVerdict::NotRun,
                policy_rejected: None,
            })],
            cursor: 1,
            status: RunLiveStatus::InProgress,
        };
        let json = serde_json::to_string(&resp).unwrap();
        let back: RunSubscribeResponse = serde_json::from_str(&json).unwrap();
        assert_eq!(back.cursor, 1);
        assert_eq!(back.turns_so_far.len(), 1);
        assert!(matches!(back.turns_so_far[0], RunRecord::Turn(_)));
    }

    // FIX 1: the canonical harness builds the outcome itself and sends
    // `{status, summary, evidence, metrics, tools_called}` with NO `timestamp`
    // and an EXTRA `tools_called` field. RunCompleteRequest.outcome must
    // deserialize this shape, otherwise `runs.complete` fails and the run is
    // never marked ended (recorded Incomplete).
    #[test]
    fn run_complete_request_accepts_harness_outcome_shape() {
        let req_json = serde_json::json!({
            "run_id": "r1",
            "outcome": {
                "status": "success",
                "summary": "Created file",
                "evidence": [],
                "metrics": {
                    "turns": 3,
                    "tool_calls": 3,
                    "actions_succeeded": 3,
                    "actions_failed": 0
                },
                "tools_called": ["drive_cli", "check_outcome", "finish"]
            }
        });

        let req: RunCompleteRequest =
            serde_json::from_value(req_json).expect("harness outcome shape must deserialize");
        assert_eq!(req.run_id, "r1");
        assert_eq!(req.outcome.status, car_ir::OutcomeStatus::Success);
        assert_eq!(req.outcome.summary, "Created file");
        assert_eq!(req.outcome.metrics.turns, 3);
        assert_eq!(req.outcome.metrics.tool_calls, 3);
        assert_eq!(req.outcome.metrics.actions_succeeded, 3);
        assert_eq!(req.outcome.metrics.actions_failed, 0);
        // omitted metrics fields default to zero
        assert_eq!(req.outcome.metrics.duration_ms, 0.0);
        assert_eq!(req.outcome.metrics.retries, 0);
    }
}
