| 1 | use codewhale_protocol::{ |
| 2 | AppRequest, EventFrame, ThreadGoal, ThreadGoalProgressParams, ThreadGoalSetParams, |
| 3 | ThreadGoalStatus, ThreadListParams, ThreadRequest, ThreadResumeParams, ToolOutput, |
| 4 | UserInputAnswerEvent, UserInputOptionEvent, UserInputQuestionEvent, UserInputRequestEvent, |
| 5 | runtime::{RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION, RuntimeEventEnvelope}, |
| 6 | }; |
| 7 | use serde_json::{Value, json}; |
| 8 | |
| 9 | #[test] |
| 10 | fn mcp_tool_output_public_shape_remains_source_compatible() { |
| 11 | let output = ToolOutput::Mcp { |
| 12 | result: json!({"content": []}), |
| 13 | }; |
| 14 | let ToolOutput::Mcp { result } = output else { |
| 15 | panic!("constructed MCP output changed variant") |
| 16 | }; |
| 17 | assert_eq!(result, json!({"content": []})); |
| 18 | } |
| 19 | |
| 20 | #[test] |
| 21 | fn tool_output_success_accessor_has_function_and_mcp_parity() { |
| 22 | let cases = [ |
| 23 | ( |
| 24 | ToolOutput::Function { |
| 25 | body: Some(json!({"kind": "function-success"})), |
| 26 | success: true, |
| 27 | }, |
| 28 | true, |
| 29 | ), |
| 30 | ( |
| 31 | ToolOutput::Function { |
| 32 | body: Some(json!({"kind": "function-failure"})), |
| 33 | success: false, |
| 34 | }, |
| 35 | false, |
| 36 | ), |
| 37 | ( |
| 38 | ToolOutput::Mcp { |
| 39 | result: json!({"kind": "mcp-success", "isError": false}), |
| 40 | }, |
| 41 | true, |
| 42 | ), |
| 43 | ( |
| 44 | ToolOutput::Mcp { |
| 45 | result: json!({"kind": "mcp-failure", "isError": true}), |
| 46 | }, |
| 47 | false, |
| 48 | ), |
| 49 | ]; |
| 50 | |
| 51 | assert_eq!( |
| 52 | serde_json::to_string(&cases[0].0).expect("serialize successful function output"), |
| 53 | r#"{"type":"function","body":{"kind":"function-success"},"success":true}"#, |
| 54 | "successful Function output must retain its existing wire shape" |
| 55 | ); |
| 56 | for (output, expected) in cases { |
| 57 | assert_eq!(output.success(), expected, "output: {output:?}"); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | #[test] |
| 62 | fn mcp_is_error_round_trips_and_legacy_bytes_remain_successful() { |
| 63 | let failed = ToolOutput::Mcp { |
| 64 | result: json!({"message": "application failure", "isError": true}), |
| 65 | }; |
| 66 | let encoded = serde_json::to_string(&failed).expect("serialize failed MCP output"); |
| 67 | assert!(encoded.contains(r#""isError":true"#)); |
| 68 | let decoded: ToolOutput = serde_json::from_str(&encoded).expect("round-trip MCP output"); |
| 69 | assert!(!decoded.success()); |
| 70 | |
| 71 | let legacy = r#"{"type":"mcp","result":{"message":"legacy success"}}"#; |
| 72 | let decoded: ToolOutput = serde_json::from_str(legacy).expect("deserialize legacy MCP output"); |
| 73 | assert!(decoded.success()); |
| 74 | assert_eq!( |
| 75 | serde_json::to_string(&decoded).expect("re-serialize legacy MCP output"), |
| 76 | legacy, |
| 77 | "success=true must preserve the legacy MCP wire representation" |
| 78 | ); |
| 79 | } |
| 80 | |
| 81 | #[test] |
| 82 | fn mcp_is_error_metadata_fails_closed_when_present_but_not_boolean() { |
| 83 | for malformed in [ |
| 84 | Value::Null, |
| 85 | json!("unknown"), |
| 86 | json!(1), |
| 87 | json!({"unexpected": true}), |
| 88 | json!([false]), |
| 89 | ] { |
| 90 | let output = ToolOutput::Mcp { |
| 91 | result: json!({"content": [], "isError": malformed}), |
| 92 | }; |
| 93 | assert!(!output.success(), "malformed output: {output:?}"); |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | #[test] |
| 98 | fn thread_resume_params_round_trip() { |
| 99 | let request = ThreadRequest::Resume(ThreadResumeParams { |
| 100 | thread_id: "thread-123".to_string(), |
| 101 | history: None, |
| 102 | path: None, |
| 103 | model: Some("deepseek-v4-pro".to_string()), |
| 104 | model_provider: Some("deepseek".to_string()), |
| 105 | cwd: None, |
| 106 | approval_policy: Some("on-request".to_string()), |
| 107 | sandbox: Some("workspace-write".to_string()), |
| 108 | config: None, |
| 109 | base_instructions: Some("base".to_string()), |
| 110 | developer_instructions: Some("dev".to_string()), |
| 111 | personality: Some("default".to_string()), |
| 112 | persist_extended_history: true, |
| 113 | }); |
| 114 | |
| 115 | let encoded = serde_json::to_string(&request).expect("serialize request"); |
| 116 | let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request"); |
| 117 | match decoded { |
| 118 | ThreadRequest::Resume(params) => { |
| 119 | assert_eq!(params.thread_id, "thread-123"); |
| 120 | assert_eq!(params.model.as_deref(), Some("deepseek-v4-pro")); |
| 121 | assert!(params.persist_extended_history); |
| 122 | } |
| 123 | other => panic!("unexpected request: {other:?}"), |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | #[test] |
| 128 | fn thread_list_params_defaults_are_serializable() { |
| 129 | let request = ThreadRequest::List(ThreadListParams { |
| 130 | include_archived: false, |
| 131 | limit: Some(20), |
| 132 | }); |
| 133 | let encoded = serde_json::to_string_pretty(&request).expect("serialize list request"); |
| 134 | assert!(encoded.contains("include_archived")); |
| 135 | } |
| 136 | |
| 137 | #[test] |
| 138 | fn event_frame_serialization_contains_expected_tag() { |
| 139 | let frame = EventFrame::TurnComplete { |
| 140 | turn_id: "turn-1".to_string(), |
| 141 | }; |
| 142 | let encoded = serde_json::to_string(&frame).expect("serialize frame"); |
| 143 | assert!(encoded.contains("turn_complete")); |
| 144 | } |
| 145 | |
| 146 | #[test] |
| 147 | fn thread_goal_set_request_round_trip() { |
| 148 | let request = ThreadRequest::GoalSet(ThreadGoalSetParams { |
| 149 | thread_id: "thread-123".to_string(), |
| 150 | objective: "Release 0.8.59".to_string(), |
| 151 | token_budget: Some(42_000), |
| 152 | }); |
| 153 | |
| 154 | let encoded = serde_json::to_string(&request).expect("serialize goal request"); |
| 155 | assert!(encoded.contains("goal_set")); |
| 156 | let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request"); |
| 157 | match decoded { |
| 158 | ThreadRequest::GoalSet(params) => { |
| 159 | assert_eq!(params.thread_id, "thread-123"); |
| 160 | assert_eq!(params.objective, "Release 0.8.59"); |
| 161 | assert_eq!(params.token_budget, Some(42_000)); |
| 162 | } |
| 163 | other => panic!("unexpected request: {other:?}"), |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | #[test] |
| 168 | fn thread_goal_event_serializes_status_and_accounting() { |
| 169 | let goal = ThreadGoal { |
| 170 | thread_id: "thread-123".to_string(), |
| 171 | goal_id: "goal-1".to_string(), |
| 172 | objective: "Release 0.8.59".to_string(), |
| 173 | status: ThreadGoalStatus::BudgetLimited, |
| 174 | token_budget: Some(42_000), |
| 175 | tokens_used: 42_001, |
| 176 | time_used_seconds: 3600, |
| 177 | continuation_count: 7, |
| 178 | created_at: 1, |
| 179 | updated_at: 2, |
| 180 | }; |
| 181 | |
| 182 | let frame = EventFrame::ThreadGoalUpdated { goal }; |
| 183 | let encoded = serde_json::to_value(&frame).expect("serialize goal event"); |
| 184 | assert_eq!(encoded["event"], "thread_goal_updated"); |
| 185 | assert_eq!(encoded["goal"]["status"], "budget_limited"); |
| 186 | assert_eq!(encoded["goal"]["tokens_used"], 42_001); |
| 187 | assert_eq!(encoded["goal"]["continuation_count"], 7); |
| 188 | } |
| 189 | |
| 190 | #[test] |
| 191 | fn thread_goal_progress_request_round_trip() { |
| 192 | let request = ThreadRequest::GoalRecordProgress(ThreadGoalProgressParams { |
| 193 | thread_id: "thread-123".to_string(), |
| 194 | token_delta: 750, |
| 195 | time_delta_seconds: 9, |
| 196 | record_continuation: true, |
| 197 | }); |
| 198 | |
| 199 | let encoded = serde_json::to_string(&request).expect("serialize goal progress request"); |
| 200 | assert!(encoded.contains("goal_record_progress")); |
| 201 | let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request"); |
| 202 | match decoded { |
| 203 | ThreadRequest::GoalRecordProgress(params) => { |
| 204 | assert_eq!(params.thread_id, "thread-123"); |
| 205 | assert_eq!(params.token_delta, 750); |
| 206 | assert_eq!(params.time_delta_seconds, 9); |
| 207 | assert!(params.record_continuation); |
| 208 | } |
| 209 | other => panic!("unexpected request: {other:?}"), |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | #[test] |
| 214 | fn runtime_event_envelope_roundtrip() { |
| 215 | let input = json!({ |
| 216 | "schema_version": 1, |
| 217 | "seq": 12, |
| 218 | "event": "item.delta", |
| 219 | "kind": "item.delta", |
| 220 | "thread_id": "thr_123", |
| 221 | "turn_id": "turn_456", |
| 222 | "item_id": "item_789", |
| 223 | "timestamp": "2026-02-11T20:18:49.123Z", |
| 224 | "created_at": "2026-02-11T20:18:49.123Z", |
| 225 | "payload": { "delta": "ok", "kind": "agent_message" }, |
| 226 | }); |
| 227 | let envelope: RuntimeEventEnvelope = |
| 228 | serde_json::from_value(input).expect("deserialize runtime event envelope"); |
| 229 | assert_eq!(envelope.schema_version, 1); |
| 230 | assert_eq!(envelope.seq, 12); |
| 231 | assert_eq!(envelope.event, "item.delta"); |
| 232 | assert_eq!(envelope.kind, "item.delta"); |
| 233 | assert_eq!(envelope.thread_id, "thr_123"); |
| 234 | |
| 235 | let encoded = serde_json::to_value(&envelope).expect("serialize runtime event envelope"); |
| 236 | assert_eq!(encoded["event"], encoded["kind"]); |
| 237 | assert_eq!(encoded["schema_version"], 1); |
| 238 | assert_eq!(encoded["seq"], 12); |
| 239 | assert_eq!(encoded["thread_id"], "thr_123"); |
| 240 | assert_eq!(encoded["turn_id"], "turn_456"); |
| 241 | assert_eq!(encoded["item_id"], "item_789"); |
| 242 | assert_eq!(encoded["timestamp"], "2026-02-11T20:18:49.123Z"); |
| 243 | assert_eq!(encoded["created_at"], "2026-02-11T20:18:49.123Z"); |
| 244 | assert_eq!( |
| 245 | encoded["payload"], |
| 246 | json!({ "delta": "ok", "kind": "agent_message" }) |
| 247 | ); |
| 248 | } |
| 249 | |
| 250 | #[test] |
| 251 | fn runtime_event_envelope_defaults_to_api_schema_version() { |
| 252 | let input = json!({ |
| 253 | "seq": 15, |
| 254 | "event": "thread.started", |
| 255 | "kind": "thread.started", |
| 256 | "thread_id": "thr_default_version", |
| 257 | "timestamp": "2026-02-11T20:18:49.123Z", |
| 258 | "payload": {}, |
| 259 | }); |
| 260 | let envelope: RuntimeEventEnvelope = serde_json::from_value(input) |
| 261 | .expect("deserialize runtime event envelope without schema version"); |
| 262 | |
| 263 | assert_eq!( |
| 264 | envelope.schema_version, |
| 265 | RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION |
| 266 | ); |
| 267 | } |
| 268 | |
| 269 | #[test] |
| 270 | fn runtime_event_envelope_thread_level_keeps_turn_and_item_ids() { |
| 271 | let input = json!({ |
| 272 | "schema_version": 1, |
| 273 | "seq": 14, |
| 274 | "event": "thread.started", |
| 275 | "kind": "thread.started", |
| 276 | "thread_id": "thr_thread", |
| 277 | "timestamp": "2026-02-11T20:18:49.123Z", |
| 278 | "payload": { "thread": { "id": "thr_thread" } }, |
| 279 | }); |
| 280 | let envelope: RuntimeEventEnvelope = serde_json::from_value(input) |
| 281 | .expect("deserialize runtime event envelope without thread-level turn/item ids"); |
| 282 | assert!(envelope.turn_id.is_none()); |
| 283 | assert!(envelope.item_id.is_none()); |
| 284 | |
| 285 | let encoded = serde_json::to_value(envelope).expect("serialize runtime event envelope"); |
| 286 | assert!(encoded.get("turn_id").is_some()); |
| 287 | assert!(encoded.get("item_id").is_some()); |
| 288 | assert!(encoded["turn_id"].is_null()); |
| 289 | assert!(encoded["item_id"].is_null()); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn runtime_event_envelope_preserves_unknown_fields() { |
| 294 | let input: Value = json!({ |
| 295 | "schema_version": 1, |
| 296 | "seq": 13, |
| 297 | "event": "turn.completed", |
| 298 | "kind": "turn.completed", |
| 299 | "thread_id": "thr_unknown", |
| 300 | "timestamp": "2026-02-11T20:18:49.123Z", |
| 301 | "payload": {}, |
| 302 | "forward_compatibility_hint": "v2-ready", |
| 303 | }); |
| 304 | let envelope: RuntimeEventEnvelope = serde_json::from_value(input.clone()) |
| 305 | .expect("deserialize runtime event envelope with unknown field"); |
| 306 | assert!(envelope.extra.contains_key("forward_compatibility_hint")); |
| 307 | |
| 308 | let encoded = serde_json::to_value(envelope).expect("serialize runtime event envelope"); |
| 309 | assert_eq!(encoded["forward_compatibility_hint"], "v2-ready"); |
| 310 | assert_eq!(encoded["schema_version"], 1); |
| 311 | assert_eq!(encoded["seq"], 13); |
| 312 | assert_eq!(encoded["event"], "turn.completed"); |
| 313 | assert_eq!(encoded["kind"], "turn.completed"); |
| 314 | assert_eq!(encoded["thread_id"], "thr_unknown"); |
| 315 | assert!(encoded["turn_id"].is_null()); |
| 316 | assert!(encoded["item_id"].is_null()); |
| 317 | } |
| 318 | |
| 319 | #[test] |
| 320 | fn user_input_request_event_frame_round_trip() { |
| 321 | // issue #3102: the new EventFrame::UserInputRequest variant must tag as |
| 322 | // "user_input_request" and round-trip the full nested question schema, |
| 323 | // including the allow_free_text / multi_select booleans. |
| 324 | let frame = EventFrame::UserInputRequest { |
| 325 | request: UserInputRequestEvent { |
| 326 | call_id: "call-1".to_string(), |
| 327 | turn_id: "turn-1".to_string(), |
| 328 | request_id: "ui-1".to_string(), |
| 329 | questions: vec![UserInputQuestionEvent { |
| 330 | header: "Scope".to_string(), |
| 331 | id: "scope".to_string(), |
| 332 | question: "Which surfaces?".to_string(), |
| 333 | options: vec![ |
| 334 | UserInputOptionEvent { |
| 335 | label: "TUI".to_string(), |
| 336 | description: "Modal flow".to_string(), |
| 337 | }, |
| 338 | UserInputOptionEvent { |
| 339 | label: "All".to_string(), |
| 340 | description: "TUI + headless".to_string(), |
| 341 | }, |
| 342 | ], |
| 343 | allow_free_text: true, |
| 344 | multi_select: true, |
| 345 | }], |
| 346 | }, |
| 347 | }; |
| 348 | |
| 349 | let encoded = serde_json::to_value(&frame).expect("serialize user input frame"); |
| 350 | assert_eq!(encoded["event"], "user_input_request"); |
| 351 | assert_eq!(encoded["request"]["call_id"], "call-1"); |
| 352 | assert_eq!(encoded["request"]["request_id"], "ui-1"); |
| 353 | assert_eq!(encoded["request"]["questions"][0]["header"], "Scope"); |
| 354 | assert_eq!(encoded["request"]["questions"][0]["allow_free_text"], true); |
| 355 | assert_eq!(encoded["request"]["questions"][0]["multi_select"], true); |
| 356 | assert_eq!( |
| 357 | encoded["request"]["questions"][0]["options"][0]["label"], |
| 358 | "TUI" |
| 359 | ); |
| 360 | |
| 361 | // Round-trips back through serde. |
| 362 | let decoded: EventFrame = |
| 363 | serde_json::from_value(encoded).expect("deserialize user input frame"); |
| 364 | let EventFrame::UserInputRequest { request } = decoded else { |
| 365 | panic!("expected user_input_request frame after round-trip"); |
| 366 | }; |
| 367 | assert_eq!(request.request_id, "ui-1"); |
| 368 | assert_eq!(request.questions.len(), 1); |
| 369 | assert!(request.questions[0].allow_free_text); |
| 370 | assert!(request.questions[0].multi_select); |
| 371 | } |
| 372 | |
| 373 | #[test] |
| 374 | fn user_input_request_event_defaults_flags_when_omitted() { |
| 375 | // Backwards compatibility: omitting allow_free_text/multi_select in the |
| 376 | // wire JSON must deserialize both to false (matching the TUI's leniency). |
| 377 | let input = json!({ |
| 378 | "event": "user_input_request", |
| 379 | "request": { |
| 380 | "call_id": "c", |
| 381 | "turn_id": "t", |
| 382 | "request_id": "r", |
| 383 | "questions": [{ |
| 384 | "header": "H", |
| 385 | "id": "i", |
| 386 | "question": "Q?", |
| 387 | "options": [ |
| 388 | { "label": "A", "description": "a" }, |
| 389 | { "label": "B", "description": "b" } |
| 390 | ] |
| 391 | }] |
| 392 | } |
| 393 | }); |
| 394 | let decoded: EventFrame = serde_json::from_value(input).expect("deserialize without flags"); |
| 395 | let EventFrame::UserInputRequest { request } = decoded else { |
| 396 | panic!("expected user_input_request frame"); |
| 397 | }; |
| 398 | assert!(!request.questions[0].allow_free_text); |
| 399 | assert!(!request.questions[0].multi_select); |
| 400 | } |
| 401 | |
| 402 | #[test] |
| 403 | fn submit_user_input_app_request_round_trip() { |
| 404 | // issue #3102: the headless client→server reply variant must tag as |
| 405 | // "submit_user_input" and carry the answer list. |
| 406 | let req = AppRequest::SubmitUserInput { |
| 407 | request_id: "ui-1".to_string(), |
| 408 | answers: vec![UserInputAnswerEvent { |
| 409 | id: "scope".to_string(), |
| 410 | label: "All".to_string(), |
| 411 | value: "All".to_string(), |
| 412 | }], |
| 413 | }; |
| 414 | let encoded = serde_json::to_string(&req).expect("serialize submit request"); |
| 415 | assert!(encoded.contains("submit_user_input")); |
| 416 | assert!(encoded.contains("\"request_id\":\"ui-1\"")); |
| 417 | |
| 418 | let decoded: AppRequest = serde_json::from_str(&encoded).expect("deserialize submit request"); |
| 419 | let AppRequest::SubmitUserInput { |
| 420 | request_id, |
| 421 | answers, |
| 422 | } = decoded |
| 423 | else { |
| 424 | panic!("expected submit_user_input after round-trip"); |
| 425 | }; |
| 426 | assert_eq!(request_id, "ui-1"); |
| 427 | assert_eq!(answers.len(), 1); |
| 428 | assert_eq!(answers[0].label, "All"); |
| 429 | } |
| 430 |