Skip to main content

codewhale_protocol/
op.rs

1//! `Op`-in API in `crates/protocol` (issue #5261).
2//!
3//! The TUI engine already had an internal channel (`Op` in
4//! `crates/tui/src/core/ops.rs` with `tx_op` / `rx_op` and `tx_steer`).
5//! This protocol file formalizes that channel so TUI, CLI, app-server, and
6//! tests share one serializable API. The wire is `OpEnvelope` + `Op`;
7//! transports that already speak JSON (app-server, tests) can send the
8//! envelope directly, while in-process callers continue to use the typed
9//! enum.
10
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14use crate::ids::{SessionId, ThreadId};
15
16/// Every `Op` is paired with the ids that route it. This is the
17/// `Op`-in half of the `Op`-in / `EventMsg`-out contract.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct OpEnvelope {
20    /// Monotonic `op:<n>` for dedup / tracing within a session.
21    pub op_id: String,
22    pub thread_id: ThreadId,
23    pub session_id: SessionId,
24    pub op: Op,
25}
26
27/// Operations that can be submitted to the core engine. This is the
28/// protocol view of `crates/tui/src/core/ops::Op` — same lifecycle,
29/// same provenance gate — but serializable and free of `mpsc` / `oneshot`
30/// fields. In-process callers convert at the boundary; out-of-process
31/// callers send the JSON directly.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(tag = "kind", rename_all = "snake_case")]
34pub enum Op {
35    /// Drive one model turn: `role=user` content plus the resolved route
36    /// receipt the engine will freeze at the client-freeze boundary. Headless
37    /// and TUI must produce byte-identical `MessageRequest`s for identical
38    /// `Op::SendMessage` payloads.
39    SendMessage {
40        content: String,
41        /// Effective mode for this turn (`"plan" | "agent" | "operate"` etc).
42        #[serde(default = "default_mode")]
43        mode: String,
44        /// Optional explicit route/model the caller resolved already (mirrors
45        /// `ResolvedRuntimeRoute` in `crates_tui::route_runtime`). `None` means
46        /// "use the thread's current route".
47        #[serde(skip_serializing_if = "Option::is_none")]
48        model: Option<String>,
49        #[serde(skip_serializing_if = "Option::is_none")]
50        model_provider: Option<String>,
51        /// Tool restriction from slash-command frontmatter.
52        #[serde(default)]
53        allowed_tools: Option<Vec<String>>,
54        /// Runtime-supplied dynamic tools for this turn only.
55        #[serde(default)]
56        dynamic_tools: Vec<Value>,
57        /// Structural input provenance — only `ExternalUser` may inherit
58        /// YOLO/auto-approval authority (mirrors `UserInputProvenance`).
59        #[serde(default = "default_provenance")]
60        provenance: String,
61    },
62
63    /// Steer an in-flight turn with additional user content (drains into
64    /// the turn loop's `rx_steer` channel).
65    Steer {
66        content: String,
67    },
68
69    /// Re-check and dispatch a goal continuation (synthetic turn that
70    /// continues the same logical goal run).
71    ContinueGoal,
72
73    /// Execute a local composer shell command without a model turn.
74    RunShellCommand {
75        command: String,
76    },
77
78    /// Set goal status without dispatching a model turn.
79    SetGoalStatus {
80        status: String,
81        #[serde(default)]
82        clear: bool,
83    },
84
85    Cancel,
86    Shutdown,
87
88    /// Describe the exact request the next turn would send without sending it
89    /// (`/dryrun` / `/preview-request`, #1004). Headless and TUI must render
90    /// identical manifests for identical inputs.
91    PreviewOutboundRequest {
92        #[serde(default)]
93        json: bool,
94        #[serde(default)]
95        base_prompt_only: bool,
96    },
97}
98
99fn default_mode() -> String {
100    "agent".to_string()
101}
102
103fn default_provenance() -> String {
104    "external_user".to_string()
105}
106
107impl Op {
108    #[must_use]
109    pub fn is_send_message(&self) -> bool {
110        matches!(self, Self::SendMessage { .. })
111    }
112
113    #[must_use]
114    pub fn kind_str(&self) -> &'static str {
115        match self {
116            Self::SendMessage { .. } => "send_message",
117            Self::Steer { .. } => "steer",
118            Self::ContinueGoal => "continue_goal",
119            Self::RunShellCommand { .. } => "run_shell_command",
120            Self::SetGoalStatus { .. } => "set_goal_status",
121            Self::Cancel => "cancel",
122            Self::Shutdown => "shutdown",
123            Self::PreviewOutboundRequest { .. } => "preview_outbound_request",
124        }
125    }
126}
127
128/// Build a headless `SendMessage` envelope with fresh ids. This is the
129/// one-line helper every headless caller (CLI `exec`, app-server, tests)
130/// uses so TUI and headless start a session identically.
131#[must_use]
132pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into<String>) -> OpEnvelope {
133    OpEnvelope {
134        op_id: format!("op-{}", uuid::Uuid::new_v4()),
135        thread_id: thread_id.clone(),
136        session_id: SessionId::new(),
137        op: Op::SendMessage {
138            content: content.into(),
139            mode: default_mode(),
140            model: None,
141            model_provider: None,
142            allowed_tools: None,
143            dynamic_tools: Vec::new(),
144            provenance: default_provenance(),
145        },
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn op_envelope_roundtrip() {
155        let env = headless_send_message_op(ThreadId::new(), "hello");
156        let json = serde_json::to_string(&env).unwrap();
157        let back: OpEnvelope = serde_json::from_str(&json).unwrap();
158        assert_eq!(back.thread_id, env.thread_id);
159        assert!(back.op.is_send_message());
160    }
161
162    #[test]
163    fn steer_roundtrip() {
164        let op = Op::Steer {
165            content: "more".into(),
166        };
167        let json = serde_json::to_string(&op).unwrap();
168        let back: Op = serde_json::from_str(&json).unwrap();
169        assert_eq!(back.kind_str(), "steer");
170    }
171}