返回 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 operation_key: None,
101 thread_id: "thread-123".to_string(),
102 history: None,
103 path: None,
104 model: Some("deepseek-v4-pro".to_string()),
105 model_provider: Some("deepseek".to_string()),
106 cwd: None,
107 approval_policy: Some("on-request".to_string()),
108 sandbox: Some("workspace-write".to_string()),
109 config: None,
110 base_instructions: Some("base".to_string()),
111 developer_instructions: Some("dev".to_string()),
112 personality: Some("default".to_string()),
113 persist_extended_history: true,
114 });
115
116 let encoded = serde_json::to_string(&request).expect("serialize request");
117 let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
118 match decoded {
119 ThreadRequest::Resume(params) => {
120 assert_eq!(params.thread_id, "thread-123");
121 assert_eq!(params.model.as_deref(), Some("deepseek-v4-pro"));
122 assert!(params.persist_extended_history);
123 }
124 other => panic!("unexpected request: {other:?}"),
125 }
126 }
127
128 #[test]
129 fn thread_list_params_defaults_are_serializable() {
130 let request = ThreadRequest::List(ThreadListParams {
131 include_archived: false,
132 limit: Some(20),
133 });
134 let encoded = serde_json::to_string_pretty(&request).expect("serialize list request");
135 assert!(encoded.contains("include_archived"));
136 }
137
138 #[test]
139 fn event_frame_serialization_contains_expected_tag() {
140 let frame = EventFrame::TurnComplete {
141 turn_id: "turn-1".to_string(),
142 };
143 let encoded = serde_json::to_string(&frame).expect("serialize frame");
144 assert!(encoded.contains("turn_complete"));
145 }
146
147 #[test]
148 fn thread_goal_set_request_round_trip() {
149 let request = ThreadRequest::GoalSet(ThreadGoalSetParams {
150 thread_id: "thread-123".to_string(),
151 objective: "Release 0.8.59".to_string(),
152 token_budget: Some(42_000),
153 });
154
155 let encoded = serde_json::to_string(&request).expect("serialize goal request");
156 assert!(encoded.contains("goal_set"));
157 let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
158 match decoded {
159 ThreadRequest::GoalSet(params) => {
160 assert_eq!(params.thread_id, "thread-123");
161 assert_eq!(params.objective, "Release 0.8.59");
162 assert_eq!(params.token_budget, Some(42_000));
163 }
164 other => panic!("unexpected request: {other:?}"),
165 }
166 }
167
168 #[test]
169 fn thread_goal_event_serializes_status_and_accounting() {
170 let goal = ThreadGoal {
171 thread_id: "thread-123".to_string(),
172 goal_id: "goal-1".to_string(),
173 objective: "Release 0.8.59".to_string(),
174 status: ThreadGoalStatus::BudgetLimited,
175 token_budget: Some(42_000),
176 tokens_used: 42_001,
177 time_used_seconds: 3600,
178 continuation_count: 7,
179 last_gap_fingerprint: None,
180 repeated_gap_count: 0,
181 last_gap_pass: None,
182 pause_reason: None,
183 created_at: 1,
184 updated_at: 2,
185 };
186
187 let frame = EventFrame::ThreadGoalUpdated { goal };
188 let encoded = serde_json::to_value(&frame).expect("serialize goal event");
189 assert_eq!(encoded["event"], "thread_goal_updated");
190 assert_eq!(encoded["goal"]["status"], "budget_limited");
191 assert_eq!(encoded["goal"]["tokens_used"], 42_001);
192 assert_eq!(encoded["goal"]["continuation_count"], 7);
193 }
194
195 #[test]
196 fn thread_goal_progress_request_round_trip() {
197 let request = ThreadRequest::GoalRecordProgress(ThreadGoalProgressParams {
198 thread_id: "thread-123".to_string(),
199 token_delta: 750,
200 time_delta_seconds: 9,
201 record_continuation: true,
202 });
203
204 let encoded = serde_json::to_string(&request).expect("serialize goal progress request");
205 assert!(encoded.contains("goal_record_progress"));
206 let decoded: ThreadRequest = serde_json::from_str(&encoded).expect("deserialize request");
207 match decoded {
208 ThreadRequest::GoalRecordProgress(params) => {
209 assert_eq!(params.thread_id, "thread-123");
210 assert_eq!(params.token_delta, 750);
211 assert_eq!(params.time_delta_seconds, 9);
212 assert!(params.record_continuation);
213 }
214 other => panic!("unexpected request: {other:?}"),
215 }
216 }
217
218 #[test]
219 fn runtime_event_envelope_roundtrip() {
220 let input = json!({
221 "schema_version": 1,
222 "seq": 12,
223 "event": "item.delta",
224 "kind": "item.delta",
225 "thread_id": "thr_123",
226 "turn_id": "turn_456",
227 "item_id": "item_789",
228 "timestamp": "2026-02-11T20:18:49.123Z",
229 "created_at": "2026-02-11T20:18:49.123Z",
230 "payload": { "delta": "ok", "kind": "agent_message" },
231 });
232 let envelope: RuntimeEventEnvelope =
233 serde_json::from_value(input).expect("deserialize runtime event envelope");
234 assert_eq!(envelope.schema_version, 1);
235 assert_eq!(envelope.seq, 12);
236 assert_eq!(envelope.event, "item.delta");
237 assert_eq!(envelope.kind, "item.delta");
238 assert_eq!(envelope.thread_id, "thr_123");
239
240 let encoded = serde_json::to_value(&envelope).expect("serialize runtime event envelope");
241 assert_eq!(encoded["event"], encoded["kind"]);
242 assert_eq!(encoded["schema_version"], 1);
243 assert_eq!(encoded["seq"], 12);
244 assert_eq!(encoded["thread_id"], "thr_123");
245 assert_eq!(encoded["turn_id"], "turn_456");
246 assert_eq!(encoded["item_id"], "item_789");
247 assert_eq!(encoded["timestamp"], "2026-02-11T20:18:49.123Z");
248 assert_eq!(encoded["created_at"], "2026-02-11T20:18:49.123Z");
249 assert_eq!(
250 encoded["payload"],
251 json!({ "delta": "ok", "kind": "agent_message" })
252 );
253 }
254
255 #[test]
256 fn runtime_event_envelope_defaults_to_api_schema_version() {
257 let input = json!({
258 "seq": 15,
259 "event": "thread.started",
260 "kind": "thread.started",
261 "thread_id": "thr_default_version",
262 "timestamp": "2026-02-11T20:18:49.123Z",
263 "payload": {},
264 });
265 let envelope: RuntimeEventEnvelope = serde_json::from_value(input)
266 .expect("deserialize runtime event envelope without schema version");
267
268 assert_eq!(
269 envelope.schema_version,
270 RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION
271 );
272 }
273
274 #[test]
275 fn runtime_event_envelope_thread_level_keeps_turn_and_item_ids() {
276 let input = json!({
277 "schema_version": 1,
278 "seq": 14,
279 "event": "thread.started",
280 "kind": "thread.started",
281 "thread_id": "thr_thread",
282 "timestamp": "2026-02-11T20:18:49.123Z",
283 "payload": { "thread": { "id": "thr_thread" } },
284 });
285 let envelope: RuntimeEventEnvelope = serde_json::from_value(input)
286 .expect("deserialize runtime event envelope without thread-level turn/item ids");
287 assert!(envelope.turn_id.is_none());
288 assert!(envelope.item_id.is_none());
289
290 let encoded = serde_json::to_value(envelope).expect("serialize runtime event envelope");
291 assert!(encoded.get("turn_id").is_some());
292 assert!(encoded.get("item_id").is_some());
293 assert!(encoded["turn_id"].is_null());
294 assert!(encoded["item_id"].is_null());
295 }
296
297 #[test]
298 fn runtime_event_envelope_preserves_unknown_fields() {
299 let input: Value = json!({
300 "schema_version": 1,
301 "seq": 13,
302 "event": "turn.completed",
303 "kind": "turn.completed",
304 "thread_id": "thr_unknown",
305 "timestamp": "2026-02-11T20:18:49.123Z",
306 "payload": {},
307 "forward_compatibility_hint": "v2-ready",
308 });
309 let envelope: RuntimeEventEnvelope = serde_json::from_value(input.clone())
310 .expect("deserialize runtime event envelope with unknown field");
311 assert!(envelope.extra.contains_key("forward_compatibility_hint"));
312
313 let encoded = serde_json::to_value(envelope).expect("serialize runtime event envelope");
314 assert_eq!(encoded["forward_compatibility_hint"], "v2-ready");
315 assert_eq!(encoded["schema_version"], 1);
316 assert_eq!(encoded["seq"], 13);
317 assert_eq!(encoded["event"], "turn.completed");
318 assert_eq!(encoded["kind"], "turn.completed");
319 assert_eq!(encoded["thread_id"], "thr_unknown");
320 assert!(encoded["turn_id"].is_null());
321 assert!(encoded["item_id"].is_null());
322 }
323
324 #[test]
325 fn user_input_request_event_frame_round_trip() {
326 // issue #3102: the new EventFrame::UserInputRequest variant must tag as
327 // "user_input_request" and round-trip the full nested question schema,
328 // including the allow_free_text / multi_select booleans.
329 let frame = EventFrame::UserInputRequest {
330 request: UserInputRequestEvent {
331 call_id: "call-1".to_string(),
332 turn_id: "turn-1".to_string(),
333 request_id: "ui-1".to_string(),
334 questions: vec![UserInputQuestionEvent {
335 header: "Scope".to_string(),
336 id: "scope".to_string(),
337 question: "Which surfaces?".to_string(),
338 options: vec![
339 UserInputOptionEvent {
340 label: "TUI".to_string(),
341 description: "Modal flow".to_string(),
342 },
343 UserInputOptionEvent {
344 label: "All".to_string(),
345 description: "TUI + headless".to_string(),
346 },
347 ],
348 allow_free_text: true,
349 multi_select: true,
350 }],
351 },
352 };
353
354 let encoded = serde_json::to_value(&frame).expect("serialize user input frame");
355 assert_eq!(encoded["event"], "user_input_request");
356 assert_eq!(encoded["request"]["call_id"], "call-1");
357 assert_eq!(encoded["request"]["request_id"], "ui-1");
358 assert_eq!(encoded["request"]["questions"][0]["header"], "Scope");
359 assert_eq!(encoded["request"]["questions"][0]["allow_free_text"], true);
360 assert_eq!(encoded["request"]["questions"][0]["multi_select"], true);
361 assert_eq!(
362 encoded["request"]["questions"][0]["options"][0]["label"],
363 "TUI"
364 );
365
366 // Round-trips back through serde.
367 let decoded: EventFrame =
368 serde_json::from_value(encoded).expect("deserialize user input frame");
369 let EventFrame::UserInputRequest { request } = decoded else {
370 panic!("expected user_input_request frame after round-trip");
371 };
372 assert_eq!(request.request_id, "ui-1");
373 assert_eq!(request.questions.len(), 1);
374 assert!(request.questions[0].allow_free_text);
375 assert!(request.questions[0].multi_select);
376 }
377
378 #[test]
379 fn user_input_request_event_defaults_flags_when_omitted() {
380 // Backwards compatibility: omitting allow_free_text/multi_select in the
381 // wire JSON must deserialize both to false (matching the TUI's leniency).
382 let input = json!({
383 "event": "user_input_request",
384 "request": {
385 "call_id": "c",
386 "turn_id": "t",
387 "request_id": "r",
388 "questions": [{
389 "header": "H",
390 "id": "i",
391 "question": "Q?",
392 "options": [
393 { "label": "A", "description": "a" },
394 { "label": "B", "description": "b" }
395 ]
396 }]
397 }
398 });
399 let decoded: EventFrame = serde_json::from_value(input).expect("deserialize without flags");
400 let EventFrame::UserInputRequest { request } = decoded else {
401 panic!("expected user_input_request frame");
402 };
403 assert!(!request.questions[0].allow_free_text);
404 assert!(!request.questions[0].multi_select);
405 }
406
407 #[test]
408 fn submit_user_input_app_request_round_trip() {
409 // issue #3102: the headless client→server reply variant must tag as
410 // "submit_user_input" and carry the answer list.
411 let req = AppRequest::SubmitUserInput {
412 request_id: "ui-1".to_string(),
413 answers: vec![UserInputAnswerEvent {
414 id: "scope".to_string(),
415 label: "All".to_string(),
416 value: "All".to_string(),
417 }],
418 };
419 let encoded = serde_json::to_string(&req).expect("serialize submit request");
420 assert!(encoded.contains("submit_user_input"));
421 assert!(encoded.contains("\"request_id\":\"ui-1\""));
422
423 let decoded: AppRequest = serde_json::from_str(&encoded).expect("deserialize submit request");
424 let AppRequest::SubmitUserInput {
425 request_id,
426 answers,
427 } = decoded
428 else {
429 panic!("expected submit_user_input after round-trip");
430 };
431 assert_eq!(request_id, "ui-1");
432 assert_eq!(answers.len(), 1);
433 assert_eq!(answers[0].label, "All");
434 }
435
436 #[test]
437 fn retained_thread_operation_keys_are_additive_and_survive_explicit_retry() {
438 for (method, params) in [
439 ("start", json!({})),
440 ("resume", json!({"thread_id":"thread-123"})),
441 ("fork", json!({"thread_id":"thread-123"})),
442 ] {
443 let legacy = json!({"type":method,"params":params});
444 // Use the enum's actual serde tag rather than a second fixture protocol.
445 let mut wire = match method {
446 "start" => serde_json::to_value(ThreadRequest::Start(
447 serde_json::from_value(json!({})).unwrap(),
448 ))
449 .unwrap(),
450 "resume" => serde_json::to_value(ThreadRequest::Resume(
451 serde_json::from_value(legacy["params"].clone()).unwrap(),
452 ))
453 .unwrap(),
454 _ => serde_json::to_value(ThreadRequest::Fork(
455 serde_json::from_value(legacy["params"].clone()).unwrap(),
456 ))
457 .unwrap(),
458 };
459 assert!(wire.get("operation_key").is_none());
460 wire["operation_key"] = json!("captured-client-intent");
461 let decoded: ThreadRequest = serde_json::from_value(wire).unwrap();
462 assert_eq!(
463 serde_json::to_value(decoded).unwrap()["operation_key"],
464 "captured-client-intent"
465 );
466 }
467 }
468
468 lines RUST