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}