返回 CodeWhale
op.rs
根目录 / crates / protocol / src / op.rs
1 //! `Op`-in API in `crates/protocol` (issue #5261, Phase A1 of the
2 //! core/protocol extraction spec).
3 //!
4 //! The TUI engine already had an internal channel (`Op` in
5 //! `crates/tui/src/core/ops.rs` with `tx_op` / `rx_op` and `tx_steer`).
6 //! This protocol file formalizes that channel so TUI, CLI, app-server, and
7 //! tests share one serializable API. The wire is `OpEnvelope` + `Op`;
8 //! transports that already speak JSON (app-server, tests) can send the
9 //! envelope directly, while in-process callers continue to use the typed
10 //! enum.
11 //!
12 //! Parity is compile-enforced from the engine side:
13 //! `crates/tui/src/core/protocol_parity.rs` matches every engine `Op`
14 //! variant exhaustively into a protocol `Op` (`protocol_covers_engine_ops`).
15 //!
16 //! What is deliberately stripped at this boundary:
17 //!
18 //! - `mpsc` / `oneshot` reply channels (`GetSubAgentSettlement`, `GetSessionSnapshot`,
19 //! `GetContextBudget`, `GetProviderRuntimeStatus`, `BootstrapMcp`, `RetryMcpServer`,
20 //! `ReloadMcp`). Over the wire the reply is an `EventMsg` or a response
21 //! frame, not a channel.
22 //! - `Arc<HookExecutor>` on `SendMessage`: hooks are host configuration, not
23 //! turn input.
24 //! - Resolved provider routes and clients. Only the non-secret receipt
25 //! (`model`, `model_provider`) crosses; the engine re-resolves.
26 //! - `Ruleset`, transcript `Message`s, and `SystemPrompt` cross as
27 //! `serde_json::Value` from their own `Serialize` until typed in a later
28 //! phase.
29 //!
30 //! `Steer` and `Cancel` have no engine `Op` twin on purpose: the engine
31 //! carries them on `tx_steer` and the cancellation token. They are protocol
32 //! operations regardless, because every out-of-process client needs them.
33
34 use std::path::PathBuf;
35
36 use serde::{Deserialize, Serialize};
37 use serde_json::Value;
38
39 /// Accept `engine_schedule_id` on the wire for compatibility, then discard it.
40 ///
41 /// Deserialization is the out-of-process boundary: the engine's own
42 /// `Op::ContinueGoal` travels an in-process channel and never reaches here, so
43 /// anything this sees was supplied by a caller who must not be able to set it.
44 /// Returning `None` sends such a request down the ordinary host-injected path
45 /// instead of letting it consume a pending engine schedule.
46 fn drop_engine_owned_schedule_id<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
47 where
48 D: serde::Deserializer<'de>,
49 {
50 Option::<u64>::deserialize(deserializer)?;
51 Ok(None)
52 }
53
54 use crate::ids::{SessionId, ThreadId};
55 use crate::runtime::DynamicToolSpec;
56
57 /// Every `Op` is paired with the ids that route it. This is the
58 /// `Op`-in half of the `Op`-in / `EventMsg`-out contract.
59 #[derive(Debug, Clone, Serialize, Deserialize)]
60 pub struct OpEnvelope {
61 /// Monotonic `op:<n>` for dedup / tracing within a session.
62 pub op_id: String,
63 pub thread_id: ThreadId,
64 pub session_id: SessionId,
65 pub op: Op,
66 }
67
68 /// Token-limit facts for a route. `None` is unknown, never zero.
69 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
70 pub struct RouteLimits {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub context_tokens: Option<u64>,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub input_tokens: Option<u64>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub output_tokens: Option<u64>,
77 }
78
79 /// Compaction policy derived from a provider route. Twin of the engine's
80 /// `CompactionConfig`.
81 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82 pub struct CompactionPolicy {
83 pub enabled: bool,
84 pub token_threshold: u64,
85 pub model: String,
86 /// `supported | unsupported | unknown`.
87 #[serde(default = "default_capability_state")]
88 pub image_input: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub effective_context_window: Option<u32>,
91 #[serde(default)]
92 pub cache_summary: bool,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub focus: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub runtime_cost_owner: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub workspace: Option<PathBuf>,
99 }
100
101 fn default_capability_state() -> String {
102 "unknown".to_string()
103 }
104
105 /// Per-turn authority payload carried by [`Op::SendMessage`]. Serializable
106 /// twin of `crates/tui/src/core/ops::TurnSpec`; extracted from the enum arm so
107 /// new per-turn fields accrete here instead of widening the variant. The
108 /// `SendMessage(TurnSpec)` newtype keeps the internally-tagged wire shape
109 /// byte-identical: `{"kind":"send_message", ...fields}`.
110 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
111 pub struct TurnSpec {
112 #[serde(
113 default,
114 rename = "maxOutputTokens",
115 alias = "max_output_tokens",
116 skip_serializing_if = "Option::is_none"
117 )]
118 pub max_output_tokens: Option<std::num::NonZeroU32>,
119 pub content: String,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub images: Vec<crate::runtime::RuntimeImageInput>,
122 /// Effective mode for this turn (`"plan" | "agent" | "operate"` etc).
123 #[serde(default = "default_mode")]
124 pub mode: String,
125 /// Optional explicit route/model the caller resolved already (mirrors
126 /// `ResolvedRuntimeRoute` in `crates_tui::route_runtime`). `None` means
127 /// "use the thread's current route".
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub model: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub model_provider: Option<String>,
132 /// Tool restriction from slash-command frontmatter.
133 #[serde(default)]
134 pub allowed_tools: Option<Vec<String>>,
135 /// Runtime-supplied dynamic tools for this turn only.
136 #[serde(default)]
137 pub dynamic_tools: Vec<DynamicToolSpec>,
138 /// Structural input provenance — only `external_user` may inherit
139 /// YOLO/auto-approval authority (mirrors `UserInputProvenance`).
140 #[serde(default = "default_provenance")]
141 pub provenance: String,
142 /// Compaction policy carried atomically with the route receipt.
143 /// Boxed only to keep the enum small; the wire shape is unchanged.
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub compaction: Option<Box<CompactionPolicy>>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub goal_objective: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub goal_token_budget: Option<u32>,
150 /// `active | paused | complete | blocked`.
151 #[serde(default = "default_goal_status")]
152 pub goal_status: String,
153 /// `"off" | "low" | "medium" | "high" | "max"`; `None` = provider default.
154 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub reasoning_effort: Option<String>,
156 #[serde(default)]
157 pub reasoning_effort_auto: bool,
158 #[serde(default)]
159 pub auto_model: bool,
160 #[serde(default)]
161 pub allow_shell: bool,
162 #[serde(default)]
163 pub trust_mode: bool,
164 #[serde(default)]
165 pub auto_approve: bool,
166 /// `auto | bypass | suggest | never`.
167 #[serde(default = "default_approval_mode")]
168 pub approval_mode: String,
169 #[serde(default)]
170 pub translation_enabled: bool,
171 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub verbosity: Option<String>,
173 }
174
175 /// Operations that can be submitted to the core engine. This is the
176 /// protocol view of `crates/tui/src/core/ops::Op` — same lifecycle,
177 /// same provenance gate — but serializable and free of `mpsc` / `oneshot`
178 /// fields. In-process callers convert at the boundary; out-of-process
179 /// callers send the JSON directly.
180 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181 #[serde(tag = "kind", rename_all = "snake_case")]
182 pub enum Op {
183 /// Drive one model turn: `role=user` content plus the resolved route
184 /// receipt the engine will freeze at the client-freeze boundary. Headless
185 /// and TUI must produce byte-identical `MessageRequest`s for identical
186 /// `Op::SendMessage` payloads.
187 SendMessage(TurnSpec),
188
189 /// Steer an in-flight turn with additional user content (drains into
190 /// the turn loop's `rx_steer` channel).
191 Steer {
192 content: String,
193 },
194
195 /// Re-check and dispatch a goal continuation (synthetic turn that
196 /// continues the same logical goal run).
197 ContinueGoal {
198 #[serde(default)]
199 dynamic_tools: Vec<DynamicToolSpec>,
200 /// Engine-owned coalescing token; direct callers send `None`.
201 ///
202 /// Enforced, not merely documented: the engine mints these on an
203 /// in-process channel (`tx_op.try_send`) and they never cross serde,
204 /// so any value arriving through deserialization came from outside.
205 /// The IDs are predictable per-session counters, and a matching guess
206 /// is treated as an already-delayed internal token -- it would consume
207 /// the pending schedule and skip the host-injected quiet period. Wire
208 /// input is therefore always dropped to `None`.
209 #[serde(
210 default,
211 skip_serializing_if = "Option::is_none",
212 deserialize_with = "drop_engine_owned_schedule_id"
213 )]
214 engine_schedule_id: Option<u64>,
215 },
216
217 /// Execute a local composer shell command without a model turn.
218 RunShellCommand {
219 command: String,
220 #[serde(default = "default_mode")]
221 mode: String,
222 #[serde(default)]
223 allow_shell: bool,
224 #[serde(default)]
225 trust_mode: bool,
226 #[serde(default)]
227 auto_approve: bool,
228 #[serde(default = "default_approval_mode")]
229 approval_mode: String,
230 },
231
232 /// Set goal status without dispatching a model turn.
233 SetGoalStatus {
234 status: String,
235 #[serde(default)]
236 clear: bool,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 goal_id: Option<String>,
239 },
240
241 /// Set (or replace) the active goal objective and start goal work.
242 SetGoalObjective {
243 objective: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 token_budget: Option<u32>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 goal_id: Option<String>,
248 },
249
250 Cancel,
251 Shutdown,
252
253 /// Describe the exact request the next turn would send without sending it
254 /// (`/dryrun` / `/preview-request`, #1004). Headless and TUI must render
255 /// identical manifests for identical inputs.
256 PreviewOutboundRequest {
257 #[serde(default)]
258 json: bool,
259 #[serde(default)]
260 base_prompt_only: bool,
261 #[serde(default = "default_mode")]
262 mode: String,
263 #[serde(default)]
264 allow_shell: bool,
265 #[serde(default)]
266 trust_mode: bool,
267 #[serde(default)]
268 auto_approve: bool,
269 #[serde(default = "default_approval_mode")]
270 approval_mode: String,
271 #[serde(default)]
272 allowed_tools: Option<Vec<String>>,
273 #[serde(default)]
274 dynamic_tools: Vec<DynamicToolSpec>,
275 #[serde(default = "default_provenance")]
276 provenance: String,
277 /// The model selector the user chose (`auto` when auto routing).
278 #[serde(default)]
279 requested_model: String,
280 #[serde(default)]
281 requested_reasoning: String,
282 #[serde(default)]
283 auto_model: bool,
284 #[serde(default)]
285 hypothetical_prompt_supplied: bool,
286 /// Model-facing text of the hypothetical next message, when the host
287 /// planner resolved one.
288 #[serde(default, skip_serializing_if = "Option::is_none")]
289 hypothetical_prompt: Option<String>,
290 /// Why no exact next turn exists, when it does not.
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 unresolved: Option<String>,
293 },
294
295 ListSubAgents,
296 /// Inspect live children and pending handbacks at the Engine's idle
297 /// boundary. The host owns the response channel; it is not wire input.
298 GetSubAgentSettlement,
299 CancelSubAgent {
300 agent_id: String,
301 },
302 FollowUpSubAgent {
303 agent_id: String,
304 text: String,
305 },
306
307 ChangeMode {
308 #[serde(default = "default_mode")]
309 mode: String,
310 #[serde(default)]
311 allow_shell: bool,
312 #[serde(default)]
313 trust_mode: bool,
314 #[serde(default)]
315 auto_approve: bool,
316 #[serde(default = "default_approval_mode")]
317 approval_mode: String,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 configured_sandbox_mode: Option<String>,
320 },
321
322 SetModel {
323 model: String,
324 #[serde(default = "default_mode")]
325 mode: String,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 route_limits: Option<RouteLimits>,
328 },
329
330 SetCompaction {
331 config: CompactionPolicy,
332 },
333
334 /// Legacy serialized permission-update operation, retained for wire
335 /// compatibility. The in-process runtime now publishes directly through
336 /// its shared policy store instead of queuing a second replacement.
337 SetPermissionRuleset {
338 ruleset: Value,
339 },
340
341 SetStreamChunkTimeout {
342 timeout_secs: u64,
343 },
344
345 SetSubagentRuntimeConfig {
346 enabled: bool,
347 max_subagents: u64,
348 launch_concurrency: u64,
349 max_spawn_depth: u32,
350 api_timeout_secs: u64,
351 heartbeat_timeout_secs: u64,
352 },
353
354 /// `SearchProvider` in snake_case.
355 SetSearchProvider {
356 provider: String,
357 },
358
359 /// Replace the engine's merged Fleet roster. Only the roster's identity
360 /// crosses: member ids in precedence order plus the load state.
361 SetFleetRoster {
362 member_ids: Vec<String>,
363 #[serde(default)]
364 exact_selection: bool,
365 #[serde(default, skip_serializing_if = "Option::is_none")]
366 load_error: Option<String>,
367 },
368
369 /// Sync engine session state (resume/load). `messages` are transcript
370 /// `Message`s and `system_prompt` a `SystemPrompt`, both serialized.
371 SyncSession {
372 #[serde(default, skip_serializing_if = "Option::is_none")]
373 engine_session_id: Option<String>,
374 messages: Vec<Value>,
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 system_prompt: Option<Value>,
377 #[serde(default)]
378 system_prompt_override: bool,
379 model: String,
380 workspace: PathBuf,
381 #[serde(default = "default_mode")]
382 mode: String,
383 },
384
385 /// Run context compaction on one exact provider route.
386 CompactContext {
387 id: String,
388 model: String,
389 model_provider: String,
390 compaction: CompactionPolicy,
391 },
392
393 CancelCompaction {
394 id: String,
395 },
396
397 /// Request a session snapshot; the reply travels out-of-band.
398 GetSessionSnapshot,
399 /// Request the live context-window budget for the session's route; the
400 /// reply travels out-of-band.
401 GetContextBudget,
402 /// Request provider concurrency state; the reply travels out-of-band.
403 GetProviderRuntimeStatus,
404 /// Populate the engine-owned MCP pool once at boot; reply out-of-band.
405 BootstrapMcp,
406 RetryMcpServer {
407 name: String,
408 },
409 ReloadMcp {
410 config_path: PathBuf,
411 },
412
413 PurgeContext,
414 EditLastTurn {
415 new_message: String,
416 },
417 SetAdvisorEnabled {
418 enabled: bool,
419 },
420 }
421
422 fn default_mode() -> String {
423 "agent".to_string()
424 }
425
426 fn default_provenance() -> String {
427 "external_user".to_string()
428 }
429
430 fn default_goal_status() -> String {
431 "active".to_string()
432 }
433
434 fn default_approval_mode() -> String {
435 "suggest".to_string()
436 }
437
438 /// Every wire tag `Op` can carry, in declaration order.
439 pub const OP_KINDS: &[&str] = &[
440 "send_message",
441 "steer",
442 "continue_goal",
443 "run_shell_command",
444 "set_goal_status",
445 "set_goal_objective",
446 "cancel",
447 "shutdown",
448 "preview_outbound_request",
449 "list_sub_agents",
450 "get_sub_agent_settlement",
451 "cancel_sub_agent",
452 "follow_up_sub_agent",
453 "change_mode",
454 "set_model",
455 "set_compaction",
456 "set_permission_ruleset",
457 "set_stream_chunk_timeout",
458 "set_subagent_runtime_config",
459 "set_search_provider",
460 "set_fleet_roster",
461 "sync_session",
462 "compact_context",
463 "cancel_compaction",
464 "get_session_snapshot",
465 "get_context_budget",
466 "get_provider_runtime_status",
467 "bootstrap_mcp",
468 "retry_mcp_server",
469 "reload_mcp",
470 "purge_context",
471 "edit_last_turn",
472 "set_advisor_enabled",
473 ];
474
475 impl Op {
476 #[must_use]
477 pub fn is_send_message(&self) -> bool {
478 matches!(self, Self::SendMessage(_))
479 }
480
481 #[must_use]
482 pub fn kind_str(&self) -> &'static str {
483 match self {
484 Self::SendMessage(_) => "send_message",
485 Self::Steer { .. } => "steer",
486 Self::ContinueGoal { .. } => "continue_goal",
487 Self::RunShellCommand { .. } => "run_shell_command",
488 Self::SetGoalStatus { .. } => "set_goal_status",
489 Self::SetGoalObjective { .. } => "set_goal_objective",
490 Self::Cancel => "cancel",
491 Self::Shutdown => "shutdown",
492 Self::PreviewOutboundRequest { .. } => "preview_outbound_request",
493 Self::ListSubAgents => "list_sub_agents",
494 Self::GetSubAgentSettlement => "get_sub_agent_settlement",
495 Self::CancelSubAgent { .. } => "cancel_sub_agent",
496 Self::FollowUpSubAgent { .. } => "follow_up_sub_agent",
497 Self::ChangeMode { .. } => "change_mode",
498 Self::SetModel { .. } => "set_model",
499 Self::SetCompaction { .. } => "set_compaction",
500 Self::SetPermissionRuleset { .. } => "set_permission_ruleset",
501 Self::SetStreamChunkTimeout { .. } => "set_stream_chunk_timeout",
502 Self::SetSubagentRuntimeConfig { .. } => "set_subagent_runtime_config",
503 Self::SetSearchProvider { .. } => "set_search_provider",
504 Self::SetFleetRoster { .. } => "set_fleet_roster",
505 Self::SyncSession { .. } => "sync_session",
506 Self::CompactContext { .. } => "compact_context",
507 Self::CancelCompaction { .. } => "cancel_compaction",
508 Self::GetSessionSnapshot => "get_session_snapshot",
509 Self::GetContextBudget => "get_context_budget",
510 Self::GetProviderRuntimeStatus => "get_provider_runtime_status",
511 Self::BootstrapMcp => "bootstrap_mcp",
512 Self::RetryMcpServer { .. } => "retry_mcp_server",
513 Self::ReloadMcp { .. } => "reload_mcp",
514 Self::PurgeContext => "purge_context",
515 Self::EditLastTurn { .. } => "edit_last_turn",
516 Self::SetAdvisorEnabled { .. } => "set_advisor_enabled",
517 }
518 }
519 }
520
521 /// Build a headless `SendMessage` envelope with fresh ids. This is the
522 /// one-line helper every headless caller (CLI `exec`, app-server, tests)
523 /// uses so TUI and headless start a session identically.
524 #[must_use]
525 pub fn headless_send_message_op(thread_id: ThreadId, content: impl Into<String>) -> OpEnvelope {
526 OpEnvelope {
527 op_id: format!("op-{}", uuid::Uuid::new_v4()),
528 thread_id: thread_id.clone(),
529 session_id: SessionId::new(),
530 op: Op::SendMessage(TurnSpec {
531 max_output_tokens: None,
532 content: content.into(),
533 images: Vec::new(),
534 mode: default_mode(),
535 model: None,
536 model_provider: None,
537 allowed_tools: None,
538 dynamic_tools: Vec::new(),
539 provenance: default_provenance(),
540 compaction: None,
541 goal_objective: None,
542 goal_token_budget: None,
543 goal_status: default_goal_status(),
544 reasoning_effort: None,
545 reasoning_effort_auto: false,
546 auto_model: false,
547 allow_shell: false,
548 trust_mode: false,
549 auto_approve: false,
550 approval_mode: default_approval_mode(),
551 translation_enabled: false,
552 verbosity: None,
553 }),
554 }
555 }
556
557 #[cfg(test)]
558 mod tests {
559 use super::*;
560 use serde_json::json;
561
562 fn policy() -> CompactionPolicy {
563 CompactionPolicy {
564 enabled: true,
565 token_threshold: 100_000,
566 model: "deepseek-chat".into(),
567 image_input: "unknown".into(),
568 effective_context_window: Some(128_000),
569 cache_summary: true,
570 focus: None,
571 runtime_cost_owner: None,
572 workspace: None,
573 }
574 }
575
576 /// One instance of every variant, in declaration order.
577 fn every_variant() -> Vec<Op> {
578 vec![
579 headless_send_message_op(ThreadId::new(), "hello").op,
580 Op::Steer {
581 content: "more".into(),
582 },
583 Op::ContinueGoal {
584 dynamic_tools: vec![],
585 // Engine-owned: deliberately not round-trippable. See
586 // `wire_supplied_engine_schedule_id_is_dropped`.
587 engine_schedule_id: None,
588 },
589 Op::RunShellCommand {
590 command: "ls".into(),
591 mode: "agent".into(),
592 allow_shell: true,
593 trust_mode: false,
594 auto_approve: false,
595 approval_mode: "suggest".into(),
596 },
597 Op::SetGoalStatus {
598 goal_id: None,
599 status: "paused".into(),
600 clear: false,
601 },
602 Op::SetGoalObjective {
603 goal_id: None,
604 objective: "ship".into(),
605 token_budget: Some(10),
606 },
607 Op::Cancel,
608 Op::Shutdown,
609 Op::PreviewOutboundRequest {
610 json: true,
611 base_prompt_only: false,
612 mode: "plan".into(),
613 allow_shell: false,
614 trust_mode: false,
615 auto_approve: false,
616 approval_mode: "auto".into(),
617 allowed_tools: Some(vec!["read_file".into()]),
618 dynamic_tools: vec![],
619 provenance: "external_user".into(),
620 requested_model: "auto".into(),
621 requested_reasoning: "high".into(),
622 auto_model: true,
623 hypothetical_prompt_supplied: true,
624 hypothetical_prompt: Some("hi".into()),
625 unresolved: None,
626 },
627 Op::ListSubAgents,
628 Op::GetSubAgentSettlement,
629 Op::CancelSubAgent {
630 agent_id: "a1".into(),
631 },
632 Op::FollowUpSubAgent {
633 agent_id: "a1".into(),
634 text: "go".into(),
635 },
636 Op::ChangeMode {
637 mode: "operate".into(),
638 allow_shell: true,
639 trust_mode: true,
640 auto_approve: false,
641 approval_mode: "bypass".into(),
642 configured_sandbox_mode: Some("workspace-write".into()),
643 },
644 Op::SetModel {
645 model: "m".into(),
646 mode: "agent".into(),
647 route_limits: Some(RouteLimits {
648 context_tokens: Some(1),
649 input_tokens: None,
650 output_tokens: None,
651 }),
652 },
653 Op::SetCompaction { config: policy() },
654 Op::SetPermissionRuleset {
655 ruleset: json!({"rules": []}),
656 },
657 Op::SetStreamChunkTimeout { timeout_secs: 30 },
658 Op::SetSubagentRuntimeConfig {
659 enabled: true,
660 max_subagents: 4,
661 launch_concurrency: 2,
662 max_spawn_depth: 1,
663 api_timeout_secs: 60,
664 heartbeat_timeout_secs: 10,
665 },
666 Op::SetSearchProvider {
667 provider: "brave".into(),
668 },
669 Op::SetFleetRoster {
670 member_ids: vec!["scout".into()],
671 exact_selection: false,
672 load_error: None,
673 },
674 Op::SyncSession {
675 engine_session_id: Some("s".into()),
676 messages: vec![json!({"role": "user", "content": []})],
677 system_prompt: None,
678 system_prompt_override: false,
679 model: "m".into(),
680 workspace: PathBuf::from("/ws"),
681 mode: "agent".into(),
682 },
683 Op::CompactContext {
684 id: "cmp-1".into(),
685 model: "m".into(),
686 model_provider: "deepseek".into(),
687 compaction: policy(),
688 },
689 Op::CancelCompaction { id: "cmp-1".into() },
690 Op::GetSessionSnapshot,
691 Op::GetContextBudget,
692 Op::GetProviderRuntimeStatus,
693 Op::BootstrapMcp,
694 Op::RetryMcpServer { name: "fs".into() },
695 Op::ReloadMcp {
696 config_path: PathBuf::from("/tmp/mcp.json"),
697 },
698 Op::PurgeContext,
699 Op::EditLastTurn {
700 new_message: "again".into(),
701 },
702 Op::SetAdvisorEnabled { enabled: true },
703 ]
704 }
705
706 #[test]
707 fn every_variant_is_listed_once() {
708 let kinds: Vec<&str> = every_variant().iter().map(Op::kind_str).collect();
709 assert_eq!(kinds, OP_KINDS, "OP_KINDS must list every variant in order");
710 }
711
712 #[test]
713 fn wire_supplied_engine_schedule_id_is_dropped() {
714 // The engine mints these on an in-process channel, so a value arriving
715 // through serde came from an out-of-process caller. Honouring it would
716 // let a guessed counter consume the pending schedule and skip the
717 // host-injected quiet period.
718 let op: Op = serde_json::from_value(json!({
719 "kind": "continue_goal",
720 "dynamic_tools": [],
721 "engine_schedule_id": 3,
722 }))
723 .expect("continue_goal with a wire-supplied schedule id still parses");
724 match op {
725 Op::ContinueGoal {
726 engine_schedule_id, ..
727 } => assert_eq!(
728 engine_schedule_id, None,
729 "engine_schedule_id must never be settable from the wire"
730 ),
731 other => panic!("expected ContinueGoal, got {other:?}"),
732 }
733 }
734
735 #[test]
736 fn every_variant_round_trips_and_tags_by_kind() {
737 for op in every_variant() {
738 let value = serde_json::to_value(&op).unwrap();
739 assert_eq!(value["kind"], op.kind_str(), "{op:?}");
740 let back: Op = serde_json::from_value(value).unwrap();
741 assert_eq!(back, op);
742 }
743 }
744
745 #[test]
746 fn op_envelope_roundtrip() {
747 let env = headless_send_message_op(ThreadId::new(), "hello");
748 let json = serde_json::to_string(&env).unwrap();
749 let back: OpEnvelope = serde_json::from_str(&json).unwrap();
750 assert_eq!(back.thread_id, env.thread_id);
751 assert!(back.op.is_send_message());
752 }
753
754 #[test]
755 fn minimal_send_message_json_still_parses_with_defaults() {
756 // The pre-A1 wire shape: only `content` plus the tag.
757 let op: Op = serde_json::from_value(json!({
758 "kind": "send_message",
759 "content": "hello"
760 }))
761 .unwrap();
762 let Op::SendMessage(TurnSpec {
763 mode,
764 provenance,
765 goal_status,
766 approval_mode,
767 ..
768 }) = op
769 else {
770 panic!("expected send_message");
771 };
772 assert_eq!(mode, "agent");
773 assert_eq!(provenance, "external_user");
774 assert_eq!(goal_status, "active");
775 assert_eq!(approval_mode, "suggest");
776 }
777
778 #[test]
779 fn steer_roundtrip() {
780 let op = Op::Steer {
781 content: "more".into(),
782 };
783 let json = serde_json::to_string(&op).unwrap();
784 let back: Op = serde_json::from_str(&json).unwrap();
785 assert_eq!(back.kind_str(), "steer");
786 }
787 }
788
788 lines RUST