返回 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 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
430 lines RUST