返回 CodeWhale
parity_protocol.rs
根目录 / crates / protocol / tests / parity_protocol.rs
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 last_gap_fingerprint: None,
179 repeated_gap_count: 0,
180 last_gap_pass: None,
181 pause_reason: None,
182 created_at: 1,
183 updated_at: 2,
184 };
185
186 let frame = EventFrame::ThreadGoalUpdated { goal };
187 let encoded = serde_json::to_value(&frame).expect("serialize goal event");
188 assert_eq!(encoded["event"], "thread_goal_updated");
189 assert_eq!(encoded["goal"]["status"], "budget_limited");
190 assert_eq!(encoded["goal"]["tokens_used"], 42_001);
191 assert_eq!(encoded["goal"]["continuation_count"], 7);
192 }
193
194 #[test]
195 fn thread_goal_progress_request_round_trip() {
196 let request = ThreadRequest::GoalRecordProgress(ThreadGoalProgressParams {
197 thread_id: "thread-123".to_string(),
198 token_delta: 750,
199 time_delta_seconds: 9,
200 record_continuation: true,
201 });
202
203 let encoded = serde_json::to_string(&request).expect("serialize goal progress request");
204 assert!(encoded.contains("goal_record_progress"));
205 let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
206 match decoded {
207 ThreadRequest::GoalRecordProgress(params) => {
208 assert_eq!(params.thread_id, "thread-123");
209 assert_eq!(params.token_delta, 750);
210 assert_eq!(params.time_delta_seconds, 9);
211 assert!(params.record_continuation);
212 }
213 other => panic!("unexpected request: {other:?}"),
214 }
215 }
216
217 #[test]
218 fn runtime_event_envelope_roundtrip() {
219 let input = json!({
220 "schema_version": 1,
221 "seq": 12,
222 "event": "item.delta",
223 "kind": "item.delta",
224 "thread_id": "thr_123",
225 "turn_id": "turn_456",
226 "item_id": "item_789",
227 "timestamp": "2026-02-11T20:18:49.123Z",
228 "created_at": "2026-02-11T20:18:49.123Z",
229 "payload": { "delta": "ok", "kind": "agent_message" },
230 });
231 let envelope: RuntimeEventEnvelope =
232 serde_json::from_value(input).expect("deserialize runtime event envelope");
233 assert_eq!(envelope.schema_version, 1);
234 assert_eq!(envelope.seq, 12);
235 assert_eq!(envelope.event, "item.delta");
236 assert_eq!(envelope.kind, "item.delta");
237 assert_eq!(envelope.thread_id, "thr_123");
238
239 let encoded = serde_json::to_value(&envelope).expect("serialize runtime event envelope");
240 assert_eq!(encoded["event"], encoded["kind"]);
241 assert_eq!(encoded["schema_version"], 1);
242 assert_eq!(encoded["seq"], 12);
243 assert_eq!(encoded["thread_id"], "thr_123");
244 assert_eq!(encoded["turn_id"], "turn_456");
245 assert_eq!(encoded["item_id"], "item_789");
246 assert_eq!(encoded["timestamp"], "2026-02-11T20:18:49.123Z");
247 assert_eq!(encoded["created_at"], "2026-02-11T20:18:49.123Z");
248 assert_eq!(
249 encoded["payload"],
250 json!({ "delta": "ok", "kind": "agent_message" })
251 );
252 }
253
254 #[test]
255 fn runtime_event_envelope_defaults_to_api_schema_version() {
256 let input = json!({
257 "seq": 15,
258 "event": "thread.started",
259 "kind": "thread.started",
260 "thread_id": "thr_default_version",
261 "timestamp": "2026-02-11T20:18:49.123Z",
262 "payload": {},
263 });
264 let envelope: RuntimeEventEnvelope = serde_json::from_value(input)
265 .expect("deserialize runtime event envelope without schema version");
266
267 assert_eq!(
268 envelope.schema_version,
269 RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
270 );
271 }
272
273 #[test]
274 fn runtime_event_envelope_thread_level_keeps_turn_and_item_ids() {
275 let input = json!({
276 "schema_version": 1,
277 "seq": 14,
278 "event": "thread.started",
279 "kind": "thread.started",
280 "thread_id": "thr_thread",
281 "timestamp": "2026-02-11T20:18:49.123Z",
282 "payload": { "thread": { "id": "thr_thread" } },
283 });
284 let envelope: RuntimeEventEnvelope = serde_json::from_value(input)
285 .expect("deserialize runtime event envelope without thread-level turn/item ids");
286 assert!(envelope.turn_id.is_none());
287 assert!(envelope.item_id.is_none());
288
289 let encoded = serde_json::to_value(envelope).expect("serialize runtime event envelope");
290 assert!(encoded.get("turn_id").is_some());
291 assert!(encoded.get("item_id").is_some());
292 assert!(encoded["turn_id"].is_null());
293 assert!(encoded["item_id"].is_null());
294 }
295
296 #[test]
297 fn runtime_event_envelope_preserves_unknown_fields() {
298 let input: Value = json!({
299 "schema_version": 1,
300 "seq": 13,
301 "event": "turn.completed",
302 "kind": "turn.completed",
303 "thread_id": "thr_unknown",
304 "timestamp": "2026-02-11T20:18:49.123Z",
305 "payload": {},
306 "forward_compatibility_hint": "v2-ready",
307 });
308 let envelope: RuntimeEventEnvelope = serde_json::from_value(input.clone())
309 .expect("deserialize runtime event envelope with unknown field");
310 assert!(envelope.extra.contains_key("forward_compatibility_hint"));
311
312 let encoded = serde_json::to_value(envelope).expect("serialize runtime event envelope");
313 assert_eq!(encoded["forward_compatibility_hint"], "v2-ready");
314 assert_eq!(encoded["schema_version"], 1);
315 assert_eq!(encoded["seq"], 13);
316 assert_eq!(encoded["event"], "turn.completed");
317 assert_eq!(encoded["kind"], "turn.completed");
318 assert_eq!(encoded["thread_id"], "thr_unknown");
319 assert!(encoded["turn_id"].is_null());
320 assert!(encoded["item_id"].is_null());
321 }
322
323 #[test]
324 fn user_input_request_event_frame_round_trip() {
325 // issue #3102: the new EventFrame::UserInputRequest variant must tag as
326 // "user_input_request" and round-trip the full nested question schema,
327 // including the allow_free_text / multi_select booleans.
328 let frame = EventFrame::UserInputRequest {
329 request: UserInputRequestEvent {
330 call_id: "call-1".to_string(),
331 turn_id: "turn-1".to_string(),
332 request_id: "ui-1".to_string(),
333 questions: vec![UserInputQuestionEvent {
334 header: "Scope".to_string(),
335 id: "scope".to_string(),
336 question: "Which surfaces?".to_string(),
337 options: vec![
338 UserInputOptionEvent {
339 label: "TUI".to_string(),
340 description: "Modal flow".to_string(),
341 },
342 UserInputOptionEvent {
343 label: "All".to_string(),
344 description: "TUI + headless".to_string(),
345 },
346 ],
347 allow_free_text: true,
348 multi_select: true,
349 }],
350 },
351 };
352
353 let encoded = serde_json::to_value(&frame).expect("serialize user input frame");
354 assert_eq!(encoded["event"], "user_input_request");
355 assert_eq!(encoded["request"]["call_id"], "call-1");
356 assert_eq!(encoded["request"]["request_id"], "ui-1");
357 assert_eq!(encoded["request"]["questions"][0]["header"], "Scope");
358 assert_eq!(encoded["request"]["questions"][0]["allow_free_text"], true);
359 assert_eq!(encoded["request"]["questions"][0]["multi_select"], true);
360 assert_eq!(
361 encoded["request"]["questions"][0]["options"][0]["label"],
362 "TUI"
363 );
364
365 // Round-trips back through serde.
366 let decoded: EventFrame =
367 serde_json::from_value(encoded).expect("deserialize user input frame");
368 let EventFrame::UserInputRequest { request } = decoded else {
369 panic!("expected user_input_request frame after round-trip");
370 };
371 assert_eq!(request.request_id, "ui-1");
372 assert_eq!(request.questions.len(), 1);
373 assert!(request.questions[0].allow_free_text);
374 assert!(request.questions[0].multi_select);
375 }
376
377 #[test]
378 fn user_input_request_event_defaults_flags_when_omitted() {
379 // Backwards compatibility: omitting allow_free_text/multi_select in the
380 // wire JSON must deserialize both to false (matching the TUI's leniency).
381 let input = json!({
382 "event": "user_input_request",
383 "request": {
384 "call_id": "c",
385 "turn_id": "t",
386 "request_id": "r",
387 "questions": [{
388 "header": "H",
389 "id": "i",
390 "question": "Q?",
391 "options": [
392 { "label": "A", "description": "a" },
393 { "label": "B", "description": "b" }
394 ]
395 }]
396 }
397 });
398 let decoded: EventFrame = serde_json::from_value(input).expect("deserialize without flags");
399 let EventFrame::UserInputRequest { request } = decoded else {
400 panic!("expected user_input_request frame");
401 };
402 assert!(!request.questions[0].allow_free_text);
403 assert!(!request.questions[0].multi_select);
404 }
405
406 #[test]
407 fn submit_user_input_app_request_round_trip() {
408 // issue #3102: the headless client→server reply variant must tag as
409 // "submit_user_input" and carry the answer list.
410 let req = AppRequest::SubmitUserInput {
411 request_id: "ui-1".to_string(),
412 answers: vec![UserInputAnswerEvent {
413 id: "scope".to_string(),
414 label: "All".to_string(),
415 value: "All".to_string(),
416 }],
417 };
418 let encoded = serde_json::to_string(&req).expect("serialize submit request");
419 assert!(encoded.contains("submit_user_input"));
420 assert!(encoded.contains("\"request_id\":\"ui-1\""));
421
422 let decoded: AppRequest = serde_json::from_str(&encoded).expect("deserialize submit request");
423 let AppRequest::SubmitUserInput {
424 request_id,
425 answers,
426 } = decoded
427 else {
428 panic!("expected submit_user_input after round-trip");
429 };
430 assert_eq!(request_id, "ui-1");
431 assert_eq!(answers.len(), 1);
432 assert_eq!(answers[0].label, "All");
433 }
434
434 lines RUST