1use std::collections::{BTreeMap, BTreeSet};
5use std::path::PathBuf;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::Arc;
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use serde_json::{json, Map, Value};
11use supercode::{
12 ChatMessage, FrontendApprovalDecision, FrontendAttachment, FrontendElicitationAction,
13 FrontendEvent, FrontendRequestKind, FrontendResponse, FrontendRuntimeDescriptor, Role,
14 SdkError, SdkErrorCode, SdkRuntime,
15};
16
17pub const PROTOCOL_NAMESPACE: &str = "codex_app_server/v0_144";
19pub const CODEX_CLI_VERSION: &str = "0.144.4";
21pub const HISTORY_LIMIT: usize = 4096;
23
24const TRACED_METHODS: &[&str] = &[
25 "account/read",
26 "configRequirements/read",
27 "hooks/list",
28 "initialize",
29 "initialized",
30 "model/list",
31 "skills/list",
32 "thread/goal/get",
33 "thread/read",
34 "thread/resume",
35 "thread/start",
36 "thread/unsubscribe",
37 "turn/start",
38];
39
40const SCHEMA_ONLY_METHODS: &[&str] = &[
41 "thread/archive",
42 "thread/fork",
43 "thread/list",
44 "turn/interrupt",
45 "turn/steer",
46];
47
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
50pub enum CodexCompatibilityMode {
51 #[default]
53 TracedOnly,
54 SchemaExtended,
56}
57
58#[derive(Debug, thiserror::Error)]
60pub enum AdapterError {
61 #[error("codex protocol version mismatch: expected {expected}, received {received}")]
62 ProtocolVersionMismatch {
63 expected: &'static str,
64 received: String,
65 },
66 #[error("method not supported by this adapter version: {0}")]
67 UnsupportedMethod(String),
68 #[error("invalid parameters for `{method}`: {message}")]
69 InvalidParams { method: String, message: String },
70 #[error("thread `{0}` is not attached to this runtime")]
71 UnknownThread(String),
72 #[error("deterministic identifier collision for `{0}`")]
73 IdentifierCollision(String),
74 #[error(transparent)]
75 Sdk(#[from] SdkError),
76}
77
78impl AdapterError {
79 fn wire(&self, id: Value) -> Value {
80 let (code, name) = match self {
81 Self::ProtocolVersionMismatch { .. } => (-32040, "protocol_version_mismatch"),
82 Self::UnsupportedMethod(_) => (-32020, "unsupported_action"),
83 Self::InvalidParams { .. } | Self::UnknownThread(_) => (-32602, "invalid_params"),
84 Self::IdentifierCollision(_) => (-32603, "identifier_collision"),
85 Self::Sdk(error) if error.code() == SdkErrorCode::Unauthenticated => {
86 (-32030, "unauthenticated")
87 }
88 Self::Sdk(error) if error.code() == SdkErrorCode::Unauthorized => {
89 (-32031, "unauthorized")
90 }
91 Self::Sdk(error) if error.code() == SdkErrorCode::ControllerRequired => {
92 (-32032, "controller_required")
93 }
94 Self::Sdk(error) if error.code() == SdkErrorCode::LeaseExpired => {
95 (-32033, "lease_expired")
96 }
97 Self::Sdk(error) if error.code() == SdkErrorCode::UnsupportedAction => {
98 (-32020, "unsupported_action")
99 }
100 Self::Sdk(error) if error.code() == SdkErrorCode::Busy => (-32000, "busy"),
101 Self::Sdk(_) => (-32000, "sdk_error"),
102 };
103 json!({"id": id, "error": {"code": code, "message": self.to_string(), "data": {"name": name}}})
104 }
105}
106
107pub struct CodexAppServerAdapter {
109 runtime: Arc<dyn SdkRuntime>,
110 mode: CodexCompatibilityMode,
111 cwd: PathBuf,
112 codex_home: PathBuf,
113 next_turn: AtomicU64,
114}
115
116impl CodexAppServerAdapter {
117 pub fn new(runtime: Arc<dyn SdkRuntime>, cwd: impl Into<PathBuf>) -> Arc<Self> {
118 Self::with_mode(runtime, cwd, CodexCompatibilityMode::TracedOnly)
119 }
120
121 pub fn with_mode(
122 runtime: Arc<dyn SdkRuntime>,
123 cwd: impl Into<PathBuf>,
124 mode: CodexCompatibilityMode,
125 ) -> Arc<Self> {
126 let cwd = cwd.into();
127 Self::with_mode_and_client_home(runtime, cwd.clone(), cwd, mode)
128 }
129
130 pub fn with_mode_and_client_home(
134 runtime: Arc<dyn SdkRuntime>,
135 cwd: impl Into<PathBuf>,
136 codex_home: impl Into<PathBuf>,
137 mode: CodexCompatibilityMode,
138 ) -> Arc<Self> {
139 Arc::new(Self {
140 runtime,
141 mode,
142 cwd: cwd.into(),
143 codex_home: codex_home.into(),
144 next_turn: AtomicU64::new(0),
145 })
146 }
147
148 pub fn namespace(&self) -> &'static str {
149 PROTOCOL_NAMESPACE
150 }
151
152 pub fn enabled_methods(&self) -> BTreeSet<&'static str> {
153 let mut methods = TRACED_METHODS.iter().copied().collect::<BTreeSet<_>>();
154 if self.mode == CodexCompatibilityMode::SchemaExtended {
155 methods.extend(SCHEMA_ONLY_METHODS.iter().copied());
156 }
157 methods
158 }
159
160 pub(crate) async fn detach(&self) {
161 let _ = self.runtime.detach().await;
162 }
163
164 pub fn connection(self: &Arc<Self>) -> CodexConnection {
165 CodexConnection {
166 adapter: self.clone(),
167 initialize_seen: false,
168 initialized: false,
169 attached: None,
170 descriptor: None,
171 thread_id: None,
172 active_turn: None,
173 live_event_turn: None,
174 open_agent_item: None,
175 open_agent_text: String::new(),
176 open_reasoning_item: None,
177 open_reasoning_text: String::new(),
178 open_tools: BTreeMap::new(),
179 pending_requests: BTreeMap::new(),
180 }
181 }
182
183 fn mapped_thread_id(&self, canonical: &str) -> Result<String, AdapterError> {
184 Ok(deterministic_uuid("thread", canonical))
185 }
186
187 fn validate_thread(&self, thread: &str, canonical: &str) -> Result<(), AdapterError> {
188 if thread == deterministic_uuid("thread", canonical) {
189 Ok(())
190 } else {
191 Err(AdapterError::UnknownThread(thread.to_owned()))
192 }
193 }
194}
195
196pub struct CodexConnection {
199 adapter: Arc<CodexAppServerAdapter>,
200 initialize_seen: bool,
201 initialized: bool,
202 attached: Option<FrontendAttachment>,
203 descriptor: Option<FrontendRuntimeDescriptor>,
204 thread_id: Option<String>,
205 active_turn: Option<String>,
206 live_event_turn: Option<String>,
207 open_agent_item: Option<String>,
208 open_agent_text: String,
209 open_reasoning_item: Option<String>,
210 open_reasoning_text: String,
211 open_tools: BTreeMap<String, (String, Value)>,
212 pending_requests: BTreeMap<ValueKey, u64>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
216enum ValueKey {
217 Number(u64),
218 String(String),
219}
220
221impl CodexConnection {
222 pub(crate) fn is_attached(&self) -> bool {
223 self.attached.is_some()
224 }
225
226 pub async fn handle(&mut self, message: Value) -> Vec<Value> {
229 let id = message.get("id").cloned().unwrap_or(Value::Null);
230 match self.handle_inner(&message).await {
231 Ok(mut response) => {
232 if !id.is_null() {
233 let result = if response.is_empty() {
234 Value::Null
235 } else {
236 response.remove(0)
237 };
238 response.insert(0, json!({"id": id, "result": result}));
239 }
240 response
241 }
242 Err(error) if id.is_null() => vec![json!({"method": "error", "params": {
243 "error": {"message": error.to_string(), "codexErrorInfo": "other", "additionalDetails": {"name": wire_error_name(&error)}},
244 "willRetry": false,
245 "threadId": self.thread_id,
246 "turnId": self.active_turn,
247 }})],
248 Err(error) => vec![error.wire(id)],
249 }
250 }
251
252 async fn handle_inner(&mut self, message: &Value) -> Result<Vec<Value>, AdapterError> {
253 let method = message
254 .get("method")
255 .and_then(Value::as_str)
256 .ok_or_else(|| AdapterError::InvalidParams {
257 method: "<missing>".into(),
258 message: "method must be a string".into(),
259 })?;
260 if !self.adapter.enabled_methods().contains(method) {
261 return Err(AdapterError::UnsupportedMethod(method.to_owned()));
262 }
263 if method != "initialize" && !self.initialize_seen {
264 return Err(invalid(method, "initialize must be the first request"));
265 }
266 if method != "initialize" && method != "initialized" && !self.initialized {
267 return Err(invalid(
268 method,
269 "initialized notification is required first",
270 ));
271 }
272 let params = message.get("params").cloned().unwrap_or_else(|| json!({}));
273 match method {
274 "initialize" => self.initialize(params).await,
275 "initialized" => {
276 if self.initialized {
277 return Err(invalid("initialized", "notification already received"));
278 }
279 self.initialized = true;
280 Ok(Vec::new())
281 }
282 "account/read" => Ok(vec![json!({"account": null, "requiresOpenaiAuth": false})]),
283 "configRequirements/read" => Ok(vec![json!({"requirements": null})]),
284 "hooks/list" => Ok(vec![
285 json!({"data": [{"cwd": self.cwd(), "hooks": [], "warnings": [], "errors": []}]}),
286 ]),
287 "model/list" => self.model_list().await,
288 "skills/list" => self.skills_list().await,
289 "thread/goal/get" => Ok(vec![json!({"goal": null})]),
290 "thread/start" | "thread/resume" | "thread/read" => {
291 self.attach_thread(method, ¶ms).await
292 }
293 "thread/unsubscribe" => self.unsubscribe(¶ms).await,
294 "turn/start" => self.start_turn(¶ms).await,
295 "turn/steer" => self.steer(¶ms).await,
296 "turn/interrupt" => self.interrupt(¶ms).await,
297 "thread/list" => self.thread_list().await,
298 "thread/fork" => self.thread_fork(¶ms).await,
299 "thread/archive" => self.thread_archive(¶ms).await,
300 _ => Err(AdapterError::UnsupportedMethod(method.to_owned())),
301 }
302 }
303
304 async fn initialize(&mut self, params: Value) -> Result<Vec<Value>, AdapterError> {
305 if self.initialize_seen {
306 return Err(invalid("initialize", "request already received"));
307 }
308 let received = params
309 .pointer("/clientInfo/version")
310 .and_then(Value::as_str)
311 .unwrap_or("<missing>");
312 if received != CODEX_CLI_VERSION {
313 return Err(AdapterError::ProtocolVersionMismatch {
314 expected: CODEX_CLI_VERSION,
315 received: received.to_owned(),
316 });
317 }
318 self.initialize_seen = true;
319 Ok(vec![
320 json!({
321 "userAgent": format!("supercode-codex-adapter/{CODEX_CLI_VERSION}"),
322 "codexHome": self.adapter.codex_home,
323 "platformFamily": if cfg!(unix) { "unix" } else { "windows" },
324 "platformOs": std::env::consts::OS,
325 }),
326 json!({"method": "remoteControl/status/changed", "params": {
327 "status": "disabled", "serverName": "supercode", "installationId": "supercode",
328 "environmentId": null
329 }}),
330 ])
331 }
332
333 async fn ensure_descriptor(&mut self) -> Result<FrontendRuntimeDescriptor, AdapterError> {
334 if let Some(descriptor) = &self.descriptor {
335 return Ok(descriptor.clone());
336 }
337 let descriptor = self.adapter.runtime.describe().await?;
338 self.descriptor = Some(descriptor.clone());
339 Ok(descriptor)
340 }
341
342 async fn model_list(&mut self) -> Result<Vec<Value>, AdapterError> {
343 let descriptor = self.ensure_descriptor().await?;
344 let model = descriptor.model;
345 Ok(vec![json!({"data": [{
346 "id": model, "model": model, "upgrade": null, "upgradeInfo": null,
347 "availabilityNux": null, "displayName": model, "description": "Active Supercode runtime model",
348 "hidden": false, "supportedReasoningEfforts": [], "defaultReasoningEffort": "medium",
349 "inputModalities": ["text", "image"], "supportsPersonality": false,
350 "additionalSpeedTiers": [], "serviceTiers": [], "defaultServiceTier": null, "isDefault": true
351 }], "nextCursor": null})])
352 }
353
354 async fn skills_list(&mut self) -> Result<Vec<Value>, AdapterError> {
355 Ok(vec![
356 json!({"data": [{"cwd": self.cwd(), "skills": [], "errors": []}]}),
360 ])
361 }
362
363 async fn attach_thread(
364 &mut self,
365 method: &str,
366 params: &Value,
367 ) -> Result<Vec<Value>, AdapterError> {
368 if method != "thread/start" {
369 let requested = required_str(params, "threadId", method)?;
370 let descriptor = self.ensure_descriptor().await?;
371 self.adapter
372 .validate_thread(requested, &descriptor.session_id)?;
373 }
374 let attachment = self.adapter.runtime.attach(HISTORY_LIMIT).await?;
375 let canonical = attachment.descriptor.session_id.clone();
376 let thread_id = self.adapter.mapped_thread_id(&canonical)?;
377 if let Some(descriptor) = &self.descriptor {
378 if descriptor.session_id != canonical {
379 return Err(AdapterError::IdentifierCollision(canonical));
380 }
381 }
382 let response = thread_response(
383 &attachment,
384 &thread_id,
385 &self.adapter.cwd,
386 method == "thread/read",
387 );
388 let started = json!({"method": "thread/started", "params": {"thread": thread_json(&attachment, &thread_id, &self.adapter.cwd)}});
389 self.descriptor = Some(attachment.descriptor.clone());
390 self.thread_id = Some(thread_id);
391 self.attached = Some(attachment);
392 if method == "thread/start" {
393 let thread_id = self.thread_id.as_deref().unwrap();
394 let descriptor = self.descriptor.as_ref().unwrap();
395 Ok(vec![
396 response,
397 started,
398 notification(
399 "thread/settings/updated",
400 json!({"threadId":thread_id,"threadSettings":{
401 "cwd":self.cwd(),"approvalPolicy":"on-request","approvalsReviewer":"user",
402 "sandboxPolicy":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},
403 "activePermissionProfile":null,"model":descriptor.model,"modelProvider":"supercode",
404 "serviceTier":null,"effort":null,"summary":null,
405 "collaborationMode":{"mode":"default","settings":{"model":descriptor.model,"reasoning_effort":null,"developer_instructions":""}},
406 "multiAgentMode":"explicitRequestOnly","personality":null
407 }}),
408 ),
409 notification(
410 "account/rateLimits/updated",
411 json!({"rateLimits":{"limitId":"supercode","limitName":null,"primary":null,"secondary":null,"credits":null,"individualLimit":null,"planType":null,"rateLimitReachedType":null}}),
412 ),
413 notification("thread/goal/cleared", json!({"threadId":thread_id})),
414 ])
415 } else {
416 Ok(vec![response])
417 }
418 }
419
420 async fn unsubscribe(&mut self, params: &Value) -> Result<Vec<Value>, AdapterError> {
421 let thread = required_str(params, "threadId", "thread/unsubscribe")?;
422 let canonical = self
423 .descriptor
424 .as_ref()
425 .map(|d| d.session_id.as_str())
426 .ok_or_else(|| AdapterError::UnknownThread(thread.to_owned()))?;
427 self.adapter.validate_thread(thread, canonical)?;
428 self.adapter.runtime.detach().await?;
429 self.attached = None;
430 Ok(vec![json!({"status": "unsubscribed"})])
431 }
432
433 async fn start_turn(&mut self, params: &Value) -> Result<Vec<Value>, AdapterError> {
434 let thread = required_str(params, "threadId", "turn/start")?;
435 self.validate_attached_thread(thread)?;
436 let prompt = params
437 .get("input")
438 .and_then(Value::as_array)
439 .ok_or_else(|| invalid("turn/start", "input must be an array"))?
440 .iter()
441 .filter(|item| item.get("type").and_then(Value::as_str) == Some("text"))
442 .filter_map(|item| item.get("text").and_then(Value::as_str))
443 .collect::<Vec<_>>()
444 .join("\n");
445 if prompt.is_empty() {
446 return Err(invalid("turn/start", "at least one text input is required"));
447 }
448 let next_turn = self.adapter.next_turn.fetch_add(1, Ordering::Relaxed) + 1;
449 let canonical = self.descriptor.as_ref().unwrap().session_id.clone();
450 let turn_id = deterministic_uuid("turn", &format!("{canonical}:{next_turn}"));
451 self.adapter.runtime.clone().send_input(prompt).await?;
452 self.active_turn = Some(turn_id.clone());
453 Ok(vec![
454 json!({"turn": turn_json(&turn_id, "inProgress", None)}),
455 ])
456 }
457
458 async fn steer(&mut self, params: &Value) -> Result<Vec<Value>, AdapterError> {
459 let thread = required_str(params, "threadId", "turn/steer")?;
460 self.validate_attached_thread(thread)?;
461 let expected = required_str(params, "expectedTurnId", "turn/steer")?;
462 let active = self
463 .active_turn
464 .as_deref()
465 .ok_or_else(|| invalid("turn/steer", "no active turn"))?;
466 if expected != active {
467 return Err(invalid("turn/steer", "expectedTurnId is not active"));
468 }
469 let prompt = text_from_input(params, "turn/steer")?;
470 self.adapter.runtime.steer(prompt).await?;
471 Ok(vec![json!({"turnId": active})])
472 }
473
474 async fn interrupt(&mut self, params: &Value) -> Result<Vec<Value>, AdapterError> {
475 let thread = required_str(params, "threadId", "turn/interrupt")?;
476 self.validate_attached_thread(thread)?;
477 let turn = required_str(params, "turnId", "turn/interrupt")?;
478 if self.active_turn.as_deref() != Some(turn) {
479 return Err(invalid("turn/interrupt", "turnId is not active"));
480 }
481 self.adapter.runtime.interrupt().await?;
482 Ok(vec![json!({})])
483 }
484
485 async fn thread_list(&mut self) -> Result<Vec<Value>, AdapterError> {
486 let Some(attachment) = self.attached.as_ref() else {
487 return Ok(vec![json!({"data": [], "nextCursor": null})]);
488 };
489 let thread_id = self.thread_id.as_ref().unwrap();
490 Ok(vec![
491 json!({"data": [thread_json(attachment, thread_id, &self.adapter.cwd)], "nextCursor": null}),
492 ])
493 }
494
495 async fn thread_fork(&mut self, params: &Value) -> Result<Vec<Value>, AdapterError> {
496 let thread = required_str(params, "threadId", "thread/fork")?;
497 self.validate_attached_thread(thread)?;
498 Err(AdapterError::UnsupportedMethod(
499 "thread/fork: canonical runtime cannot be duplicated by a frontend".into(),
500 ))
501 }
502
503 async fn thread_archive(&mut self, params: &Value) -> Result<Vec<Value>, AdapterError> {
504 let thread = required_str(params, "threadId", "thread/archive")?;
505 self.validate_attached_thread(thread)?;
506 Err(AdapterError::UnsupportedMethod(
507 "thread/archive: frontend cannot mutate canonical persistence".into(),
508 ))
509 }
510
511 fn validate_attached_thread(&self, thread: &str) -> Result<(), AdapterError> {
512 let canonical = self
513 .descriptor
514 .as_ref()
515 .map(|d| d.session_id.as_str())
516 .ok_or_else(|| AdapterError::UnknownThread(thread.to_owned()))?;
517 self.adapter.validate_thread(thread, canonical)
518 }
519
520 pub async fn next_notifications(&mut self) -> Result<Vec<Value>, AdapterError> {
523 let event = self
524 .attached
525 .as_mut()
526 .ok_or_else(|| AdapterError::UnknownThread("<detached>".into()))?
527 .next_event()
528 .await?;
529 self.event_notifications(event)
530 }
531
532 fn event_notifications(&mut self, event: FrontendEvent) -> Result<Vec<Value>, AdapterError> {
533 let thread = self.thread_id.clone().unwrap_or_default();
534 if self.active_turn.is_none()
535 && self.live_event_turn.is_none()
536 && matches!(
537 event.kind.as_str(),
538 "turn_started"
539 | "user_message"
540 | "text_delta"
541 | "tool_call_started"
542 | "tool_call_completed"
543 | "usage"
544 | "request"
545 | "request_resolved"
546 | "reasoning_delta"
547 | "reasoning_summary_delta"
548 | "reasoning_completed"
549 | "plan_update"
550 | "plan_updated"
551 | "diff_update"
552 | "diff_updated"
553 | "turn_succeeded"
554 | "turn_interrupted"
555 | "turn_failed"
556 | "turn_completed"
557 )
558 {
559 self.live_event_turn = Some(deterministic_uuid(
560 "turn",
561 &format!("{thread}:event:{}", event.sequence),
562 ));
563 }
564 let turn = self
565 .active_turn
566 .clone()
567 .or_else(|| self.live_event_turn.clone())
568 .unwrap_or_else(|| deterministic_uuid("turn", &format!("{thread}:idle")));
569 let p = &event.payload;
570 let messages = match event.kind.as_str() {
571 "turn_started" => {
572 vec![
573 notification(
574 "thread/status/changed",
575 json!({"threadId": thread, "status": {"type": "active"}}),
576 ),
577 notification(
578 "turn/started",
579 json!({"threadId": thread, "turn": turn_json(&turn, "inProgress", None)}),
580 ),
581 ]
582 }
583 "user_message" => {
584 let id = item_id(event.sequence, "user");
585 let text = p.get("text").and_then(Value::as_str).unwrap_or_default();
586 let item = json!({"type": "userMessage", "id": id, "clientId": null, "content": [{"type":"text", "text": text, "text_elements": []}]});
587 vec![
588 item_notification("item/started", &thread, &turn, item.clone(), "startedAtMs"),
589 item_notification("item/completed", &thread, &turn, item, "completedAtMs"),
590 ]
591 }
592 "text_delta" => {
593 let delta = p.get("text").and_then(Value::as_str).unwrap_or_default();
594 self.open_agent_text.push_str(delta);
595 let (id, started) = match &self.open_agent_item {
596 Some(id) => (id.clone(), None),
597 None => {
598 let id = item_id(event.sequence, "agent");
599 self.open_agent_item = Some(id.clone());
600 let item = json!({"type":"agentMessage","id":id,"text":"","phase":null,"memoryCitation":null});
601 (
602 id,
603 Some(item_notification(
604 "item/started",
605 &thread,
606 &turn,
607 item,
608 "startedAtMs",
609 )),
610 )
611 }
612 };
613 let mut out = Vec::with_capacity(2);
614 if let Some(started) = started {
615 out.push(started);
616 }
617 out.push(notification(
618 "item/agentMessage/delta",
619 json!({"threadId": thread, "turnId": turn, "itemId": id, "delta": delta}),
620 ));
621 out
622 }
623 "tool_call_started" => {
624 let id = p
625 .get("id")
626 .and_then(Value::as_str)
627 .map(str::to_owned)
628 .unwrap_or_else(|| item_id(event.sequence, "tool"));
629 let name = p
630 .get("name")
631 .and_then(Value::as_str)
632 .unwrap_or("unknown_tool")
633 .to_owned();
634 let arguments = parsed_tool_arguments(p.get("arguments"));
635 self.open_tools
636 .insert(id.clone(), (name.clone(), arguments.clone()));
637 let item = tool_item(&id, &name, &arguments, None, false, p);
638 vec![item_notification(
639 "item/started",
640 &thread,
641 &turn,
642 item,
643 "startedAtMs",
644 )]
645 }
646 "tool_call_completed" => {
647 let id = p
648 .get("id")
649 .and_then(Value::as_str)
650 .map(str::to_owned)
651 .unwrap_or_else(|| item_id(event.sequence, "tool"));
652 let (name, arguments) = self.open_tools.remove(&id).unwrap_or_else(|| {
653 (
654 p.get("name")
655 .and_then(Value::as_str)
656 .unwrap_or("unknown_tool")
657 .to_owned(),
658 json!({}),
659 )
660 });
661 let failed = p.get("is_error").and_then(Value::as_bool) == Some(true);
662 let output = p
663 .get("output")
664 .and_then(Value::as_str)
665 .map(str::to_owned)
666 .unwrap_or_else(|| p.get("output").cloned().unwrap_or(Value::Null).to_string());
667 let item = tool_item(&id, &name, &arguments, Some(&output), failed, p);
668 let mut out = vec![item_notification(
669 "item/completed",
670 &thread,
671 &turn,
672 item,
673 "completedAtMs",
674 )];
675 if !failed {
676 if let Some(diff) = file_tool_diff(&name, &arguments) {
677 out.push(notification(
678 "turn/diff/updated",
679 json!({"threadId":thread,"turnId":turn,"diff":diff,"_supercode":{"tool":name,"arguments":arguments}}),
680 ));
681 }
682 }
683 out
684 }
685 "usage" => vec![notification(
686 "thread/tokenUsage/updated",
687 json!({"threadId":thread,"turnId":turn,"tokenUsage":{"total":{"inputTokens":p.get("prompt_tokens").and_then(Value::as_u64).unwrap_or(0),"cachedInputTokens":p.get("cached_tokens").and_then(Value::as_u64).unwrap_or(0),"outputTokens":p.get("completion_tokens").and_then(Value::as_u64).unwrap_or(0),"reasoningOutputTokens":0,"totalTokens":p.get("total_tokens").and_then(Value::as_u64).unwrap_or(0)},"last":null,"modelContextWindow":null}}),
688 )],
689 "reasoning_delta" | "reasoning_summary_delta" => {
690 let delta = p
691 .get("text")
692 .or_else(|| p.get("delta"))
693 .and_then(Value::as_str)
694 .unwrap_or_default();
695 self.open_reasoning_text.push_str(delta);
696 let (id, started) = match &self.open_reasoning_item {
697 Some(id) => (id.clone(), Vec::new()),
698 None => {
699 let id = item_id(event.sequence, "reasoning");
700 self.open_reasoning_item = Some(id.clone());
701 let started = vec![
702 item_notification(
703 "item/started",
704 &thread,
705 &turn,
706 json!({"type":"reasoning","id":id,"summary":[],"content":[]}),
707 "startedAtMs",
708 ),
709 notification(
710 "item/reasoning/summaryPartAdded",
711 json!({"threadId":thread,"turnId":turn,"itemId":id,"summaryIndex":0}),
712 ),
713 ];
714 (id, started)
715 }
716 };
717 let mut out = started;
718 out.push(notification(
719 "item/reasoning/summaryTextDelta",
720 json!({"threadId":thread,"turnId":turn,"itemId":id,"summaryIndex":0,"delta":delta,"_supercode":{"raw":p}}),
721 ));
722 out
723 }
724 "reasoning_completed" => self.finish_reasoning(&thread, &turn, event.sequence),
725 "plan_update" | "plan_updated" => vec![notification(
726 "turn/plan/updated",
727 json!({"threadId":thread,"turnId":turn,"plan":codex_plan(p.get("plan")),"explanation":p.get("explanation"),"_supercode":{"raw":p}}),
728 )],
729 "diff_update" | "diff_updated" => vec![notification(
730 "turn/diff/updated",
731 json!({"threadId":thread,"turnId":turn,"diff":p.get("diff").and_then(Value::as_str).unwrap_or_default(),"_supercode":{"raw":p}}),
732 )],
733 "request" => return self.request_notifications(event, &thread, &turn),
734 "request_resolved" => vec![notification(
735 "serverRequest/resolved",
736 json!({"threadId": thread, "requestId": p.get("request_id")}),
737 )],
738 "turn_succeeded" => self.finish_turn(&thread, &turn, "completed", None, event.sequence),
739 "turn_interrupted" => {
740 self.finish_turn(&thread, &turn, "interrupted", None, event.sequence)
741 }
742 "turn_failed" => {
743 let error = json!({"message":p.get("message").cloned().unwrap_or(Value::String("turn failed".into())),"codexErrorInfo":"other","additionalDetails":{"_supercode":{"raw":p}}});
744 let mut out = vec![notification(
745 "error",
746 json!({"error":error,"willRetry":false,"threadId":thread,"turnId":turn}),
747 )];
748 out.extend(self.finish_turn(&thread, &turn, "failed", Some(error), event.sequence));
749 out
750 }
751 "turn_completed" => Vec::new(),
756 "cache_warning" => vec![notification(
757 "warning",
758 json!({"message":p.get("message"),"_supercode":{"sequence":event.sequence,"raw":p}}),
759 )],
760 _ => vec![notification(
761 "warning",
762 json!({"message":format!("Supercode event `{}` has no native Codex 0.144.4 display item", event.kind),"_supercode":{"sequence":event.sequence,"raw":p}}),
763 )],
764 };
765 Ok(messages)
766 }
767
768 fn request_notifications(
769 &mut self,
770 event: FrontendEvent,
771 thread: &str,
772 turn: &str,
773 ) -> Result<Vec<Value>, AdapterError> {
774 let request = event
775 .payload
776 .get("request")
777 .ok_or_else(|| invalid("event/request", "request object missing"))?;
778 let id = request
779 .get("id")
780 .and_then(Value::as_u64)
781 .ok_or_else(|| invalid("event/request", "numeric id missing"))?;
782 let wire_id = ValueKey::Number(id);
783 self.pending_requests.insert(wire_id, id);
784 let kind: FrontendRequestKind = serde_json::from_value(
785 request
786 .get("kind")
787 .cloned()
788 .unwrap_or(Value::String("other".into())),
789 )
790 .unwrap_or(FrontendRequestKind::Other);
791 let payload = request.get("payload").cloned().unwrap_or_else(|| json!({}));
792 let method = match kind {
793 FrontendRequestKind::Approval => "item/commandExecution/requestApproval",
794 FrontendRequestKind::Elicitation => "item/tool/requestUserInput",
795 FrontendRequestKind::Other => "item/tool/requestUserInput",
796 };
797 let raw_args = payload.get("raw_args").unwrap_or(&Value::Null);
798 let command_value = payload
799 .get("command")
800 .or_else(|| payload.get("subject"))
801 .or_else(|| raw_args.get("command"))
802 .or_else(|| raw_args.get("cmd"))
803 .or_else(|| raw_args.get("path"))
804 .or_else(|| raw_args.get("file_path"))
805 .cloned()
806 .unwrap_or_else(|| {
807 Value::String(
808 payload
809 .get("tool")
810 .and_then(Value::as_str)
811 .unwrap_or("tool")
812 .to_owned(),
813 )
814 });
815 let command = match command_value {
816 Value::String(command) => command,
817 other => serde_json::to_string(&other).unwrap_or_else(|_| "tool".to_owned()),
818 };
819 let cwd = payload
820 .get("cwd")
821 .or_else(|| raw_args.get("cwd"))
822 .cloned()
823 .unwrap_or_else(|| Value::String(self.cwd()));
824 let reason = payload.get("reason").cloned().unwrap_or_else(|| {
825 json!(format!(
826 "Allow Supercode to run `{}`?",
827 payload
828 .get("tool")
829 .and_then(Value::as_str)
830 .unwrap_or("tool")
831 ))
832 });
833 let started_at_ms = std::time::SystemTime::now()
834 .duration_since(std::time::UNIX_EPOCH)
835 .map(|duration| duration.as_millis() as u64)
836 .unwrap_or_default();
837 Ok(vec![
838 json!({"id":id,"method":method,"params":{"threadId":thread,"turnId":turn,"itemId":item_id(event.sequence,"request"),"startedAtMs":started_at_ms,"environmentId":"local","reason":reason,"command":command,"cwd":cwd,"commandActions":[{"type":"unknown","command":command}],"availableDecisions":["accept","cancel"],"_supercode":{"kind":kind,"raw":payload}}}),
839 ])
840 }
841
842 fn finish_turn(
843 &mut self,
844 thread: &str,
845 turn: &str,
846 status: &str,
847 error: Option<Value>,
848 sequence: u64,
849 ) -> Vec<Value> {
850 let mut out = Vec::new();
851 if let Some(item) = self.open_agent_item.take() {
852 let text = std::mem::take(&mut self.open_agent_text);
853 out.push(item_notification("item/completed", thread, turn, json!({"type":"agentMessage","id":item,"text":text,"phase":null,"memoryCitation":null,"_supercode":{"sequence":sequence}}), "completedAtMs"));
854 }
855 out.extend(self.finish_reasoning(thread, turn, sequence));
856 out.push(notification(
857 "thread/status/changed",
858 json!({"threadId":thread,"status":{"type":"idle"}}),
859 ));
860 out.push(notification(
861 "turn/completed",
862 json!({"threadId":thread,"turn":turn_json(turn,status,error)}),
863 ));
864 self.active_turn = None;
865 self.live_event_turn = None;
866 out
867 }
868
869 fn finish_reasoning(&mut self, thread: &str, turn: &str, sequence: u64) -> Vec<Value> {
870 let Some(item) = self.open_reasoning_item.take() else {
871 return Vec::new();
872 };
873 let text = std::mem::take(&mut self.open_reasoning_text);
874 vec![item_notification(
875 "item/completed",
876 thread,
877 turn,
878 json!({"type":"reasoning","id":item,"summary":[text],"content":[],"_supercode":{"sequence":sequence}}),
879 "completedAtMs",
880 )]
881 }
882
883 pub async fn handle_server_response(
885 &mut self,
886 message: &Value,
887 ) -> Result<Vec<Value>, AdapterError> {
888 let key = value_key(
889 message
890 .get("id")
891 .ok_or_else(|| invalid("serverResponse", "id missing"))?,
892 )?;
893 let request_id = self
894 .pending_requests
895 .remove(&key)
896 .ok_or_else(|| invalid("serverResponse", "unknown request id"))?;
897 let result = message.get("result").cloned().unwrap_or_else(|| json!({}));
898 let response = match result.get("decision").and_then(Value::as_str) {
899 Some("accept") | Some("approved") => FrontendResponse::Approval {
900 request_id,
901 decision: FrontendApprovalDecision::Allow,
902 },
903 Some("acceptForSession") => FrontendResponse::Approval {
904 request_id,
905 decision: FrontendApprovalDecision::AllowForSession,
906 },
907 Some("decline") | Some("cancel") | Some("denied") => FrontendResponse::Approval {
908 request_id,
909 decision: FrontendApprovalDecision::Deny,
910 },
911 _ => FrontendResponse::Other {
912 request_id,
913 action: FrontendElicitationAction::Accept,
914 content: Some(result),
915 },
916 };
917 self.adapter.runtime.respond(response).await?;
918 Ok(vec![notification(
919 "serverRequest/resolved",
920 json!({"threadId":self.thread_id,"requestId":request_id}),
921 )])
922 }
923
924 fn cwd(&self) -> String {
925 self.adapter.cwd.to_string_lossy().into_owned()
926 }
927}
928
929fn wire_error_name(error: &AdapterError) -> &'static str {
930 match error {
931 AdapterError::ProtocolVersionMismatch { .. } => "protocol_version_mismatch",
932 AdapterError::UnsupportedMethod(_) => "unsupported_action",
933 AdapterError::InvalidParams { .. } | AdapterError::UnknownThread(_) => "invalid_params",
934 AdapterError::IdentifierCollision(_) => "identifier_collision",
935 AdapterError::Sdk(error) => match error.code() {
936 SdkErrorCode::Unauthenticated => "unauthenticated",
937 SdkErrorCode::Unauthorized => "unauthorized",
938 SdkErrorCode::ControllerRequired => "controller_required",
939 SdkErrorCode::LeaseExpired => "lease_expired",
940 SdkErrorCode::Busy => "busy",
941 SdkErrorCode::UnsupportedAction => "unsupported_action",
942 _ => "sdk_error",
943 },
944 }
945}
946
947fn invalid(method: &str, message: &str) -> AdapterError {
948 AdapterError::InvalidParams {
949 method: method.into(),
950 message: message.into(),
951 }
952}
953
954fn required_str<'a>(params: &'a Value, key: &str, method: &str) -> Result<&'a str, AdapterError> {
955 params
956 .get(key)
957 .and_then(Value::as_str)
958 .ok_or_else(|| invalid(method, &format!("{key} must be a string")))
959}
960
961fn text_from_input(params: &Value, method: &str) -> Result<String, AdapterError> {
962 if let Some(text) = params.get("text").and_then(Value::as_str) {
963 return Ok(text.to_owned());
964 }
965 params
966 .get("input")
967 .and_then(Value::as_array)
968 .and_then(|values| {
969 values
970 .iter()
971 .find_map(|value| value.get("text").and_then(Value::as_str))
972 })
973 .map(str::to_owned)
974 .ok_or_else(|| invalid(method, "text input is required"))
975}
976
977fn parsed_tool_arguments(arguments: Option<&Value>) -> Value {
978 match arguments {
979 Some(Value::String(encoded)) => serde_json::from_str(encoded)
980 .unwrap_or_else(|_| json!({"_supercodeUnparsedArguments":encoded})),
981 Some(arguments) => arguments.clone(),
982 None => json!({}),
983 }
984}
985
986fn codex_plan(plan: Option<&Value>) -> Vec<Value> {
987 plan.and_then(Value::as_array)
988 .into_iter()
989 .flatten()
990 .map(|step| {
991 let status = match step.get("status").and_then(Value::as_str) {
992 Some("in_progress") | Some("inProgress") => "inProgress",
993 Some("completed") => "completed",
994 _ => "pending",
995 };
996 json!({
997 "step":step.get("step").and_then(Value::as_str).unwrap_or_default(),
998 "status":status
999 })
1000 })
1001 .collect()
1002}
1003
1004fn tool_item(
1005 id: &str,
1006 name: &str,
1007 arguments: &Value,
1008 output: Option<&str>,
1009 failed: bool,
1010 raw: &Value,
1011) -> Value {
1012 if matches!(name, "bash" | "shell" | "exec_command") {
1013 let command = arguments
1014 .get("command")
1015 .or_else(|| arguments.get("cmd"))
1016 .and_then(Value::as_str)
1017 .unwrap_or_default();
1018 return json!({
1019 "type":"commandExecution", "id":id, "command":command,
1020 "cwd":arguments.get("cwd").and_then(Value::as_str).unwrap_or_default(),
1021 "processId":null, "source":"agent",
1022 "status":if output.is_none() {"inProgress"} else if failed {"failed"} else {"completed"},
1023 "commandActions":[{"type":"unknown","command":command}],
1024 "aggregatedOutput":output, "exitCode":if output.is_none(){Value::Null}else if failed{Value::from(1)}else{Value::from(0)},
1025 "durationMs":null, "_supercode":{"raw":raw}
1026 });
1027 }
1028 if matches!(name, "write_file" | "edit_file" | "apply_patch") {
1029 let path = arguments
1030 .get("path")
1031 .or_else(|| arguments.get("file_path"))
1032 .and_then(Value::as_str)
1033 .unwrap_or("<opaque>");
1034 let diff = file_tool_diff(name, arguments).unwrap_or_else(|| {
1035 arguments
1036 .get("patch")
1037 .or_else(|| arguments.get("content"))
1038 .or_else(|| arguments.get("new_string"))
1039 .and_then(Value::as_str)
1040 .unwrap_or_else(|| output.unwrap_or_default())
1041 .to_owned()
1042 });
1043 return json!({
1044 "type":"fileChange", "id":id,
1045 "changes":[{"path":path,"kind":{"type":"update"},"diff":diff}],
1046 "status":if output.is_none() {"inProgress"} else if failed {"failed"} else {"completed"},
1047 "_supercode":{"tool":name,"arguments":arguments,"raw":raw}
1048 });
1049 }
1050 json!({
1051 "type":"dynamicToolCall", "id":id, "namespace":null,
1052 "tool":name, "arguments":arguments,
1053 "status":if output.is_none() {"inProgress"} else if failed {"failed"} else {"completed"},
1054 "contentItems":output.map(|text| vec![json!({"type":"inputText","text":text})]),
1055 "success":output.map(|_| !failed), "durationMs":null, "_supercode":{"raw":raw}
1056 })
1057}
1058
1059fn file_tool_diff(name: &str, arguments: &Value) -> Option<String> {
1060 if !matches!(name, "write_file" | "edit_file" | "apply_patch") {
1061 return None;
1062 }
1063 if name == "apply_patch" {
1064 return arguments
1065 .get("patch")
1066 .and_then(Value::as_str)
1067 .map(str::to_owned);
1068 }
1069 let path = arguments
1070 .get("path")
1071 .or_else(|| arguments.get("file_path"))
1072 .and_then(Value::as_str)?;
1073 let old = arguments
1074 .get("old_string")
1075 .and_then(Value::as_str)
1076 .unwrap_or_default();
1077 let new = arguments
1078 .get("new_string")
1079 .or_else(|| arguments.get("content"))
1080 .and_then(Value::as_str)?;
1081 let removed = old
1082 .lines()
1083 .map(|line| format!("-{line}"))
1084 .collect::<Vec<_>>()
1085 .join("\n");
1086 let added = new
1087 .lines()
1088 .map(|line| format!("+{line}"))
1089 .collect::<Vec<_>>()
1090 .join("\n");
1091 let old_count = old.lines().count();
1092 let new_count = new.lines().count();
1093 let old_start = usize::from(old_count > 0);
1094 let new_start = usize::from(new_count > 0);
1095 Some(format!(
1096 "--- a/{path}\n+++ b/{path}\n@@ -{old_start},{old_count} +{new_start},{new_count} @@\n{removed}{}{}\n",
1097 if removed.is_empty() || added.is_empty() {
1098 ""
1099 } else {
1100 "\n"
1101 },
1102 added
1103 ))
1104}
1105
1106fn deterministic_uuid(domain: &str, canonical: &str) -> String {
1107 let mut hasher = blake3::Hasher::new();
1108 hasher.update(PROTOCOL_NAMESPACE.as_bytes());
1109 hasher.update(&[0]);
1110 hasher.update(domain.as_bytes());
1111 hasher.update(&[0]);
1112 hasher.update(canonical.as_bytes());
1113 let mut bytes = [0u8; 16];
1114 bytes.copy_from_slice(&hasher.finalize().as_bytes()[..16]);
1115 bytes[6] = (bytes[6] & 0x0f) | 0x80;
1116 bytes[8] = (bytes[8] & 0x3f) | 0x80;
1117 format!("{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}", bytes[0],bytes[1],bytes[2],bytes[3],bytes[4],bytes[5],bytes[6],bytes[7],bytes[8],bytes[9],bytes[10],bytes[11],bytes[12],bytes[13],bytes[14],bytes[15])
1118}
1119
1120fn item_id(sequence: u64, family: &str) -> String {
1121 format!("sc_{family}_{sequence:016x}")
1122}
1123
1124fn epoch_ms() -> u64 {
1125 SystemTime::now()
1126 .duration_since(UNIX_EPOCH)
1127 .unwrap_or_default()
1128 .as_millis()
1129 .min(u64::MAX as u128) as u64
1130}
1131
1132fn notification(method: &str, params: Value) -> Value {
1133 json!({"method":method,"params":params})
1134}
1135
1136fn item_notification(method: &str, thread: &str, turn: &str, item: Value, time_key: &str) -> Value {
1137 let mut params = Map::new();
1138 params.insert("item".into(), item);
1139 params.insert("threadId".into(), Value::String(thread.into()));
1140 params.insert("turnId".into(), Value::String(turn.into()));
1141 params.insert(time_key.into(), Value::from(epoch_ms()));
1142 notification(method, Value::Object(params))
1143}
1144
1145fn turn_json(id: &str, status: &str, error: Option<Value>) -> Value {
1146 json!({"id":id,"items":[],"itemsView":"notLoaded","status":status,"error":error,"startedAt":null,"completedAt":null,"durationMs":null})
1147}
1148
1149fn thread_json(attachment: &FrontendAttachment, thread_id: &str, cwd: &std::path::Path) -> Value {
1150 let items = history_items(&attachment.history);
1151 let preview = attachment
1152 .history
1153 .iter()
1154 .find(|m| m.role == Role::User)
1155 .and_then(|m| m.content.clone())
1156 .unwrap_or_default();
1157 json!({
1158 "id":thread_id,"extra":null,"sessionId":thread_id,"forkedFromId":null,"parentThreadId":null,
1159 "preview":preview,"ephemeral":false,"historyMode":"legacy","modelProvider":"supercode",
1160 "createdAt":0,"updatedAt":0,"recencyAt":0,
1161 "status":{"type":if attachment.descriptor.turn_state == supercode::FrontendTurnState::Busy {"active"} else {"idle"}},
1162 "path":null,"cwd":cwd,"cliVersion":CODEX_CLI_VERSION,"source":"appServer","threadSource":"user",
1163 "agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,
1164 "turns":if items.is_empty() { vec![] } else { vec![json!({"id":deterministic_uuid("history-turn", &attachment.descriptor.session_id),"items":items,"itemsView":"full","status":"completed","error":null,"startedAt":0,"completedAt":0,"durationMs":0})] }
1165 })
1166}
1167
1168fn thread_response(
1169 attachment: &FrontendAttachment,
1170 thread_id: &str,
1171 cwd: &std::path::Path,
1172 read_only: bool,
1173) -> Value {
1174 let thread = thread_json(attachment, thread_id, cwd);
1175 if read_only {
1176 json!({"thread":thread})
1177 } else {
1178 json!({"thread":thread,"model":attachment.descriptor.model,"modelProvider":"supercode","serviceTier":null,"cwd":cwd,"runtimeWorkspaceRoots":[cwd],"instructionSources":[],"approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"activePermissionProfile":null,"reasoningEffort":null,"multiAgentMode":"explicitRequestOnly"})
1179 }
1180}
1181
1182fn history_items(history: &[ChatMessage]) -> Vec<Value> {
1183 let mut out = Vec::new();
1184 for (index, message) in history.iter().enumerate() {
1185 let id = format!("sc_history_{index:016x}");
1186 let text = message.content.clone().unwrap_or_else(|| {
1187 message
1188 .content_parts
1189 .as_ref()
1190 .map(|parts| Value::Array(parts.clone()).to_string())
1191 .unwrap_or_default()
1192 });
1193 match message.role {
1194 Role::System => out.push(json!({"type":"reasoning","id":id,"summary":[text],"content":[],"_supercode":{"role":"system","metadata":message.metadata}})),
1195 Role::User => out.push(json!({"type":"userMessage","id":id,"clientId":null,"content":[{"type":"text","text":text,"text_elements":[]}],"_supercode":{"metadata":message.metadata}})),
1196 Role::Assistant => {
1197 if !text.is_empty() { out.push(json!({"type":"agentMessage","id":id,"text":text,"phase":null,"memoryCitation":null,"_supercode":{"metadata":message.metadata}})); }
1198 for call in message.tool_calls() {
1199 let arguments = call.function.parsed_arguments().unwrap_or_else(|_| {
1200 json!({"_supercodeUnparsedArguments": call.function.arguments})
1201 });
1202 out.push(json!({"type":"dynamicToolCall","id":call.id,"namespace":null,
1203 "tool":call.function.name,"arguments":arguments,"status":"completed",
1204 "contentItems":null,"success":null,"durationMs":null,
1205 "_supercode":{"metadata":message.metadata}}));
1206 }
1207 }
1208 Role::Tool => out.push(json!({"type":"dynamicToolCall",
1209 "id":message.tool_call_id.clone().unwrap_or(id),"namespace":null,
1210 "tool":message.name.clone().unwrap_or_else(|| "tool_result".into()),
1211 "arguments":{},"status":"completed",
1212 "contentItems":[{"type":"inputText","text":text}],"success":true,
1213 "durationMs":null,"_supercode":{"metadata":message.metadata}})),
1214 }
1215 }
1216 out
1217}
1218
1219fn value_key(value: &Value) -> Result<ValueKey, AdapterError> {
1220 if let Some(number) = value.as_u64() {
1221 return Ok(ValueKey::Number(number));
1222 }
1223 if let Some(string) = value.as_str() {
1224 return Ok(ValueKey::String(string.into()));
1225 }
1226 Err(invalid(
1227 "serverResponse",
1228 "id must be an unsigned integer or string",
1229 ))
1230}
1231
1232#[cfg(test)]
1233mod tests {
1234 use super::*;
1235 use async_trait::async_trait;
1236 use std::collections::VecDeque;
1237 use std::sync::Mutex;
1238 use tokio::sync::broadcast;
1239
1240 struct RecordingRuntime {
1241 operations: Mutex<Vec<String>>,
1242 events: broadcast::Sender<FrontendEvent>,
1243 }
1244
1245 impl RecordingRuntime {
1246 fn new() -> Arc<Self> {
1247 let (events, _) = broadcast::channel(32);
1248 Arc::new(Self {
1249 operations: Mutex::new(Vec::new()),
1250 events,
1251 })
1252 }
1253
1254 fn descriptor() -> FrontendRuntimeDescriptor {
1255 FrontendRuntimeDescriptor {
1256 schema_version: 2,
1257 session_id: "canonical-session".into(),
1258 source_harness: Some("claude-code".into()),
1259 emulation_profile: Some("claude-code".into()),
1260 active_modules: vec!["reduction".into()],
1261 commands: Vec::new(),
1262 operations: Vec::new(),
1263 actions: supercode::FrontendActions {
1264 submit: true,
1265 interrupt: true,
1266 steer: true,
1267 respond: true,
1268 detach: true,
1269 close: false,
1270 },
1271 display: supercode::FrontendDisplayCapabilities {
1272 event_kinds: vec!["text_delta".into()],
1273 opaque_fallback: true,
1274 },
1275 model: "openrouter/z-ai/glm-5.2".into(),
1276 turn_state: supercode::FrontendTurnState::Idle,
1277 connection_state: supercode::FrontendConnectionState::Connected,
1278 extensions: BTreeMap::new(),
1279 }
1280 }
1281
1282 fn record(&self, value: impl Into<String>) {
1283 self.operations
1284 .lock()
1285 .unwrap_or_else(std::sync::PoisonError::into_inner)
1286 .push(value.into());
1287 }
1288 }
1289
1290 #[async_trait]
1291 impl SdkRuntime for RecordingRuntime {
1292 async fn describe(&self) -> Result<FrontendRuntimeDescriptor, SdkError> {
1293 self.record("describe");
1294 Ok(Self::descriptor())
1295 }
1296
1297 async fn attach(&self, _history_limit: usize) -> Result<FrontendAttachment, SdkError> {
1298 self.record("attach");
1299 Ok(FrontendAttachment::from_snapshot(
1300 supercode::FrontendAttachSnapshot {
1301 descriptor: Self::descriptor(),
1302 history: vec![
1303 ChatMessage::user("fact before continuation"),
1304 ChatMessage::assistant("remembered"),
1305 ],
1306 history_cursor: 2,
1307 replay: VecDeque::new(),
1308 },
1309 self.events.subscribe(),
1310 ))
1311 }
1312
1313 async fn send_input(self: Arc<Self>, prompt: String) -> Result<(), SdkError> {
1314 self.record(format!("input:{prompt}"));
1315 Ok(())
1316 }
1317
1318 async fn submit(&self, _prompt: String) -> Result<String, SdkError> {
1319 unreachable!("adapter must use atomic send_input")
1320 }
1321
1322 async fn interrupt(&self) -> Result<bool, SdkError> {
1323 self.record("interrupt");
1324 Ok(true)
1325 }
1326
1327 async fn steer(&self, prompt: String) -> Result<(), SdkError> {
1328 self.record(format!("steer:{prompt}"));
1329 Ok(())
1330 }
1331
1332 async fn respond(&self, response: FrontendResponse) -> Result<(), SdkError> {
1333 self.record(format!("respond:{response:?}"));
1334 Ok(())
1335 }
1336
1337 async fn detach(&self) -> Result<supercode::RuntimeLeaseSnapshot, SdkError> {
1338 self.record("detach");
1339 Ok(supercode::RuntimeLeaseSnapshot {
1340 controller: None,
1341 observers: Vec::new(),
1342 lease_ttl_ms: supercode::DEFAULT_RUNTIME_LEASE_TTL_MS,
1343 })
1344 }
1345 }
1346
1347 #[test]
1348 fn deterministic_ids_are_uuid_shaped_stable_and_domain_separated() {
1349 let a = deterministic_uuid("thread", "canonical-session");
1350 assert_eq!(a, deterministic_uuid("thread", "canonical-session"));
1351 assert_ne!(a, deterministic_uuid("turn", "canonical-session"));
1352 assert_eq!(a.len(), 36);
1353 assert_eq!(&a[14..15], "8");
1354 assert!(matches!(&a[19..20], "8" | "9" | "a" | "b"));
1355 }
1356
1357 #[test]
1358 fn default_surface_is_exactly_the_sanitized_trace() {
1359 let actual = TRACED_METHODS.iter().copied().collect::<BTreeSet<_>>();
1360 assert_eq!(actual.len(), 13);
1361 assert!(!actual.contains("turn/steer"));
1362 assert!(!actual.contains("thread/fork"));
1363 }
1364
1365 #[test]
1366 fn deterministic_thread_mapping_has_no_collisions_across_large_sample() {
1367 let mut ids = BTreeSet::new();
1368 for index in 0..100_000u64 {
1369 assert!(ids.insert(deterministic_uuid("thread", &format!("canonical-{index}"))));
1370 }
1371 }
1372
1373 #[tokio::test]
1374 async fn traced_protocol_attaches_existing_runtime_and_submits_once() {
1375 let runtime = RecordingRuntime::new();
1376 let adapter = CodexAppServerAdapter::new(runtime.clone(), "/workspace");
1377 let mut connection = adapter.connection();
1378
1379 let initialized = connection
1380 .handle(json!({"id":"initialize","method":"initialize","params":{"clientInfo":{"version":"0.144.4"}}}))
1381 .await;
1382 assert_eq!(initialized[0]["id"], "initialize");
1383 assert_eq!(initialized[1]["method"], "remoteControl/status/changed");
1384 assert!(connection
1385 .handle(json!({"method":"initialized"}))
1386 .await
1387 .is_empty());
1388
1389 let started = connection
1390 .handle(json!({"id":1,"method":"thread/start","params":{}}))
1391 .await;
1392 let thread_id = started[0]["result"]["thread"]["id"]
1393 .as_str()
1394 .unwrap()
1395 .to_owned();
1396 assert_eq!(started[1]["method"], "thread/started");
1397 assert_eq!(
1398 started[0]["result"]["thread"]["turns"][0]["items"][0]["content"][0]["text"],
1399 "fact before continuation"
1400 );
1401
1402 let turn = connection
1403 .handle(json!({"id":2,"method":"turn/start","params":{"threadId":thread_id,"input":[{"type":"text","text":"continue through GLM"}]}}))
1404 .await;
1405 assert_eq!(turn[0]["result"]["turn"]["status"], "inProgress");
1406 let operations = runtime
1407 .operations
1408 .lock()
1409 .unwrap_or_else(std::sync::PoisonError::into_inner)
1410 .clone();
1411 assert_eq!(
1412 operations
1413 .iter()
1414 .filter(|operation| operation.starts_with("input:"))
1415 .count(),
1416 1
1417 );
1418 assert!(operations.contains(&"input:continue through GLM".into()));
1419 }
1420
1421 #[tokio::test]
1422 async fn reconnect_keeps_thread_identity_but_never_reuses_turn_identity() {
1423 let adapter = CodexAppServerAdapter::new(RecordingRuntime::new(), "/workspace");
1424 let mut turn_ids = Vec::new();
1425 let mut thread_ids = Vec::new();
1426 for index in 0..2 {
1427 let mut connection = adapter.connection();
1428 connection
1429 .handle(
1430 json!({"id":1,"method":"initialize","params":{"clientInfo":{"version":"0.144.4"}}}),
1431 )
1432 .await;
1433 connection.handle(json!({"method":"initialized"})).await;
1434 let started = connection
1435 .handle(json!({"id":2,"method":"thread/start","params":{}}))
1436 .await;
1437 let thread = started[0]["result"]["thread"]["id"]
1438 .as_str()
1439 .unwrap()
1440 .to_owned();
1441 let turn = connection
1442 .handle(json!({"id":3,"method":"turn/start","params":{"threadId":thread,"input":[{"type":"text","text":format!("turn {index}")}]}}))
1443 .await;
1444 thread_ids.push(thread);
1445 turn_ids.push(turn[0]["result"]["turn"]["id"].clone());
1446 }
1447 assert_eq!(thread_ids[0], thread_ids[1]);
1448 assert_ne!(turn_ids[0], turn_ids[1]);
1449 }
1450
1451 #[tokio::test]
1452 async fn version_mismatch_and_untraced_controls_fail_by_name() {
1453 let runtime = RecordingRuntime::new();
1454 let adapter = CodexAppServerAdapter::new(runtime, "/workspace");
1455 let mut connection = adapter.connection();
1456 let mismatch = connection
1457 .handle(
1458 json!({"id":1,"method":"initialize","params":{"clientInfo":{"version":"0.145.0"}}}),
1459 )
1460 .await;
1461 assert_eq!(
1462 mismatch[0]["error"]["data"]["name"],
1463 "protocol_version_mismatch"
1464 );
1465 let steer = connection
1466 .handle(json!({"id":2,"method":"turn/steer","params":{"text":"more"}}))
1467 .await;
1468 assert_eq!(steer[0]["error"]["code"], -32020);
1469 assert_eq!(steer[0]["error"]["data"]["name"], "unsupported_action");
1470 }
1471
1472 #[tokio::test]
1473 async fn explicit_schema_extended_mode_routes_steer_and_interrupt_to_the_sdk() {
1474 let runtime = RecordingRuntime::new();
1475 let adapter = CodexAppServerAdapter::with_mode(
1476 runtime.clone(),
1477 "/workspace",
1478 CodexCompatibilityMode::SchemaExtended,
1479 );
1480 let mut connection = adapter.connection();
1481 connection
1482 .handle(
1483 json!({"id":1,"method":"initialize","params":{"clientInfo":{"version":"0.144.4"}}}),
1484 )
1485 .await;
1486 connection.handle(json!({"method":"initialized"})).await;
1487 let started = connection
1488 .handle(json!({"id":2,"method":"thread/start","params":{}}))
1489 .await;
1490 let thread = started[0]["result"]["thread"]["id"]
1491 .as_str()
1492 .unwrap()
1493 .to_owned();
1494 connection
1495 .handle(json!({"id":3,"method":"turn/start","params":{"threadId":thread,"input":[{"type":"text","text":"begin"}]}}))
1496 .await;
1497 let turn = connection.active_turn.clone().unwrap();
1498 let steered = connection
1499 .handle(json!({"id":4,"method":"turn/steer","params":{"threadId":thread,"expectedTurnId":turn,"input":[{"type":"text","text":"steer now"}]}}))
1500 .await;
1501 assert_eq!(steered[0]["result"]["turnId"], turn);
1502 let interrupted = connection
1503 .handle(json!({"id":5,"method":"turn/interrupt","params":{"threadId":thread,"turnId":turn}}))
1504 .await;
1505 assert_eq!(interrupted[0]["result"], json!({}));
1506
1507 let operations = runtime
1508 .operations
1509 .lock()
1510 .unwrap_or_else(std::sync::PoisonError::into_inner)
1511 .clone();
1512 assert!(operations.contains(&"input:begin".into()));
1513 assert!(operations.contains(&"steer:steer now".into()));
1514 assert!(operations.contains(&"interrupt".into()));
1515 }
1516
1517 #[tokio::test]
1518 async fn thread_identity_is_validated_before_attach_and_persistence_controls_stay_read_only() {
1519 let runtime = RecordingRuntime::new();
1520 let adapter = CodexAppServerAdapter::with_mode(
1521 runtime.clone(),
1522 "/workspace",
1523 CodexCompatibilityMode::SchemaExtended,
1524 );
1525 let mut connection = adapter.connection();
1526 connection
1527 .handle(
1528 json!({"id":1,"method":"initialize","params":{"clientInfo":{"version":"0.144.4"}}}),
1529 )
1530 .await;
1531 connection.handle(json!({"method":"initialized"})).await;
1532
1533 let wrong = connection
1534 .handle(json!({"id":2,"method":"thread/resume","params":{"threadId":"00000000-0000-8000-8000-000000000000"}}))
1535 .await;
1536 assert_eq!(wrong[0]["error"]["data"]["name"], "invalid_params");
1537 let operations = runtime
1538 .operations
1539 .lock()
1540 .unwrap_or_else(std::sync::PoisonError::into_inner)
1541 .clone();
1542 assert_eq!(operations, ["describe"]);
1543
1544 let expected = deterministic_uuid("thread", "canonical-session");
1545 let resumed = connection
1546 .handle(json!({"id":3,"method":"thread/resume","params":{"threadId":expected}}))
1547 .await;
1548 assert_eq!(resumed[0]["result"]["thread"]["id"], expected);
1549 let listed = connection
1550 .handle(json!({"id":4,"method":"thread/list","params":{}}))
1551 .await;
1552 assert_eq!(listed[0]["result"]["data"].as_array().unwrap().len(), 1);
1553 assert_eq!(listed[0]["result"]["data"][0]["id"], expected);
1554
1555 let before_read_only_controls = runtime
1556 .operations
1557 .lock()
1558 .unwrap_or_else(std::sync::PoisonError::into_inner)
1559 .clone();
1560 for (id, method) in [(5, "thread/fork"), (6, "thread/archive")] {
1561 let response = connection
1562 .handle(json!({"id":id,"method":method,"params":{"threadId":expected}}))
1563 .await;
1564 assert_eq!(
1565 response[0]["error"]["data"]["name"], "unsupported_action",
1566 "{method}"
1567 );
1568 }
1569 assert_eq!(
1570 runtime
1571 .operations
1572 .lock()
1573 .unwrap_or_else(std::sync::PoisonError::into_inner)
1574 .as_slice(),
1575 before_read_only_controls.as_slice()
1576 );
1577 }
1578
1579 #[tokio::test]
1580 async fn initialization_order_rejects_early_and_repeated_messages() {
1581 let adapter = CodexAppServerAdapter::new(RecordingRuntime::new(), "/workspace");
1582 let mut connection = adapter.connection();
1583 let early = connection
1584 .handle(json!({"id":1,"method":"thread/start","params":{}}))
1585 .await;
1586 assert_eq!(early[0]["error"]["data"]["name"], "invalid_params");
1587
1588 let initialized_early = connection.handle(json!({"method":"initialized"})).await;
1589 assert_eq!(
1590 initialized_early[0]["params"]["error"]["additionalDetails"]["name"],
1591 "invalid_params"
1592 );
1593 connection
1594 .handle(
1595 json!({"id":2,"method":"initialize","params":{"clientInfo":{"version":"0.144.4"}}}),
1596 )
1597 .await;
1598 let before_notification = connection
1599 .handle(json!({"id":3,"method":"thread/start","params":{}}))
1600 .await;
1601 assert_eq!(
1602 before_notification[0]["error"]["data"]["name"],
1603 "invalid_params"
1604 );
1605 assert!(connection
1606 .handle(json!({"method":"initialized"}))
1607 .await
1608 .is_empty());
1609 let repeated = connection
1610 .handle(
1611 json!({"id":4,"method":"initialize","params":{"clientInfo":{"version":"0.144.4"}}}),
1612 )
1613 .await;
1614 assert_eq!(repeated[0]["error"]["data"]["name"], "invalid_params");
1615 }
1616
1617 #[test]
1618 fn live_text_events_have_one_stable_turn_and_complete_with_exact_text() {
1619 let adapter = CodexAppServerAdapter::new(RecordingRuntime::new(), "/workspace");
1620 let mut connection = adapter.connection();
1621 connection.thread_id = Some(deterministic_uuid("thread", "canonical-session"));
1622
1623 let started = connection
1624 .event_notifications(FrontendEvent {
1625 sequence: 10,
1626 kind: "turn_started".into(),
1627 payload: json!({"type":"turn_started"}),
1628 })
1629 .unwrap();
1630 let turn = started[1]["params"]["turn"]["id"]
1631 .as_str()
1632 .unwrap()
1633 .to_owned();
1634 let first = connection
1635 .event_notifications(FrontendEvent {
1636 sequence: 11,
1637 kind: "text_delta".into(),
1638 payload: json!({"type":"text_delta","text":"hello "}),
1639 })
1640 .unwrap();
1641 assert_eq!(
1642 first
1643 .iter()
1644 .map(|value| value["method"].as_str().unwrap())
1645 .collect::<Vec<_>>(),
1646 vec!["item/started", "item/agentMessage/delta"]
1647 );
1648 assert_eq!(first[0]["params"]["turnId"], turn);
1649 let item = first[0]["params"]["item"]["id"].clone();
1650 let second = connection
1651 .event_notifications(FrontendEvent {
1652 sequence: 12,
1653 kind: "text_delta".into(),
1654 payload: json!({"type":"text_delta","text":"world"}),
1655 })
1656 .unwrap();
1657 assert_eq!(second.len(), 1);
1658 assert_eq!(second[0]["params"]["turnId"], turn);
1659 assert_eq!(second[0]["params"]["itemId"], item);
1660
1661 assert!(connection
1662 .event_notifications(FrontendEvent {
1663 sequence: 13,
1664 kind: "turn_completed".into(),
1665 payload: json!({"type":"turn_completed"}),
1666 })
1667 .unwrap()
1668 .is_empty());
1669 let completed = connection
1670 .event_notifications(FrontendEvent {
1671 sequence: 14,
1672 kind: "turn_succeeded".into(),
1673 payload: json!({"type":"turn_succeeded"}),
1674 })
1675 .unwrap();
1676 assert_eq!(completed[0]["method"], "item/completed");
1677 assert_eq!(completed[0]["params"]["turnId"], turn);
1678 assert_eq!(completed[0]["params"]["item"]["id"], item);
1679 assert_eq!(completed[0]["params"]["item"]["text"], "hello world");
1680 assert_eq!(completed[2]["method"], "turn/completed");
1681 }
1682
1683 #[test]
1684 fn approval_request_decodes_the_sdk_broker_payload_for_stock_codex() {
1685 let adapter = CodexAppServerAdapter::new(RecordingRuntime::new(), "/workspace");
1686 let mut connection = adapter.connection();
1687 connection.thread_id = Some(deterministic_uuid("thread", "canonical-session"));
1688 connection.active_turn = Some(deterministic_uuid("turn", "canonical-turn"));
1689
1690 let messages = connection
1691 .event_notifications(FrontendEvent {
1692 sequence: 15,
1693 kind: "request".into(),
1694 payload: json!({
1695 "type": "request",
1696 "request": {
1697 "id": 77,
1698 "kind": "approval",
1699 "payload": {
1700 "tool": "bash",
1701 "subject": "printf stock-approval",
1702 "raw_args": {
1703 "command": "printf stock-approval",
1704 "cwd": "/workspace/project"
1705 }
1706 }
1707 }
1708 }),
1709 })
1710 .unwrap();
1711
1712 assert_eq!(messages.len(), 1);
1713 assert_eq!(
1714 messages[0]["method"],
1715 "item/commandExecution/requestApproval"
1716 );
1717 assert_eq!(messages[0]["params"]["command"], "printf stock-approval");
1718 assert_eq!(messages[0]["params"]["cwd"], "/workspace/project");
1719 assert_eq!(
1720 messages[0]["params"]["_supercode"]["raw"]["raw_args"]["command"],
1721 "printf stock-approval"
1722 );
1723 }
1724
1725 #[test]
1726 fn native_command_file_reasoning_plan_and_diff_shapes_preserve_raw_events() {
1727 let adapter = CodexAppServerAdapter::new(RecordingRuntime::new(), "/workspace");
1728 let mut connection = adapter.connection();
1729 connection.thread_id = Some(deterministic_uuid("thread", "canonical-session"));
1730
1731 let command = connection
1732 .event_notifications(FrontendEvent {
1733 sequence: 20,
1734 kind: "tool_call_started".into(),
1735 payload: json!({"type":"tool_call_started","id":"cmd","name":"bash","arguments":"{\"command\":\"cargo test\",\"cwd\":\"/workspace\"}"}),
1736 })
1737 .unwrap();
1738 assert_eq!(command[0]["params"]["item"]["type"], "commandExecution");
1739 assert_eq!(command[0]["params"]["item"]["command"], "cargo test");
1740 let command_done = connection
1741 .event_notifications(FrontendEvent {
1742 sequence: 21,
1743 kind: "tool_call_completed".into(),
1744 payload: json!({"type":"tool_call_completed","id":"cmd","name":"bash","output":"ok","is_error":false}),
1745 })
1746 .unwrap();
1747 assert_eq!(command_done[0]["params"]["item"]["aggregatedOutput"], "ok");
1748
1749 let file = connection
1750 .event_notifications(FrontendEvent {
1751 sequence: 22,
1752 kind: "tool_call_started".into(),
1753 payload: json!({"type":"tool_call_started","id":"file","name":"write_file","arguments":{"path":"proof.txt","content":"proof"}}),
1754 })
1755 .unwrap();
1756 assert_eq!(file[0]["params"]["item"]["type"], "fileChange");
1757 assert_eq!(file[0]["params"]["item"]["changes"][0]["path"], "proof.txt");
1758 assert!(file[0]["params"]["item"]["changes"][0]["diff"]
1759 .as_str()
1760 .unwrap()
1761 .contains("@@ -0,0 +1,1 @@\n+proof"));
1762 assert_eq!(
1763 file[0]["params"]["item"]["_supercode"]["tool"],
1764 "write_file"
1765 );
1766 let file_done = connection
1767 .event_notifications(FrontendEvent {
1768 sequence: 23,
1769 kind: "tool_call_completed".into(),
1770 payload: json!({"type":"tool_call_completed","id":"file","name":"write_file","output":"wrote proof.txt","is_error":false}),
1771 })
1772 .unwrap();
1773 assert_eq!(file_done[1]["method"], "turn/diff/updated");
1774 assert!(file_done[1]["params"]["diff"]
1775 .as_str()
1776 .unwrap()
1777 .contains("+proof"));
1778
1779 let reasoning = connection
1780 .event_notifications(FrontendEvent {
1781 sequence: 24,
1782 kind: "reasoning_delta".into(),
1783 payload: json!({"type":"reasoning_delta","text":"inspect","future":true}),
1784 })
1785 .unwrap();
1786 assert_eq!(reasoning[0]["method"], "item/started");
1787 assert_eq!(reasoning[2]["params"]["_supercode"]["raw"]["future"], true);
1788 let reasoning_done = connection
1789 .event_notifications(FrontendEvent {
1790 sequence: 25,
1791 kind: "reasoning_completed".into(),
1792 payload: json!({"type":"reasoning_completed"}),
1793 })
1794 .unwrap();
1795 assert_eq!(reasoning_done[0]["params"]["item"]["summary"][0], "inspect");
1796
1797 let plan = connection
1798 .event_notifications(FrontendEvent {
1799 sequence: 26,
1800 kind: "plan_update".into(),
1801 payload: json!({"type":"plan_update","plan":[{"step":"test","status":"in_progress"}]}),
1802 })
1803 .unwrap();
1804 assert_eq!(plan[0]["method"], "turn/plan/updated");
1805 let diff = connection
1806 .event_notifications(FrontendEvent {
1807 sequence: 27,
1808 kind: "diff_updated".into(),
1809 payload: json!({"type":"diff_updated","diff":"@@ proof @@"}),
1810 })
1811 .unwrap();
1812 assert_eq!(diff[0]["method"], "turn/diff/updated");
1813 assert_eq!(diff[0]["params"]["diff"], "@@ proof @@");
1814 }
1815
1816 #[tokio::test]
1817 async fn replays_every_request_from_the_pinned_stock_client_corpus() {
1818 const CORPUS: &str = include_str!(
1819 "../../../scripts/client-protocol-corpus/fixtures/codex_app_server_v0_144.jsonl"
1820 );
1821
1822 fn normalize(value: &mut Value, thread_id: Option<&str>) {
1823 match value {
1824 Value::String(text) if text == "<TMP>/project" => {
1825 *text = "/workspace".into();
1826 }
1827 Value::Object(object) => {
1828 if object.contains_key("threadId") {
1829 if let Some(thread_id) = thread_id {
1830 object.insert("threadId".into(), Value::String(thread_id.into()));
1831 }
1832 }
1833 for child in object.values_mut() {
1834 normalize(child, thread_id);
1835 }
1836 }
1837 Value::Array(array) => {
1838 for child in array {
1839 normalize(child, thread_id);
1840 }
1841 }
1842 _ => {}
1843 }
1844 }
1845
1846 let runtime = RecordingRuntime::new();
1847 let adapter = CodexAppServerAdapter::new(runtime, "/workspace");
1848 let mut connection = adapter.connection();
1849 let mut corpus_connection = 1u64;
1850 let mut thread_id = None::<String>;
1851 let mut replayed = 0usize;
1852
1853 for line in CORPUS.lines() {
1854 let envelope: Value = serde_json::from_str(line).unwrap();
1855 if envelope["direction"] != "client_to_server"
1856 || envelope["transport"] != "websocket"
1857 || !envelope["message"]["method"].is_string()
1858 {
1859 continue;
1860 }
1861 let next_connection = envelope["connection"].as_u64().unwrap();
1862 if next_connection != corpus_connection {
1863 connection = adapter.connection();
1864 corpus_connection = next_connection;
1865 }
1866 let mut request = envelope["message"].clone();
1867 normalize(&mut request, thread_id.as_deref());
1868 let method = request["method"].as_str().unwrap().to_owned();
1869 let request_id = request.get("id").cloned();
1870 let output = connection.handle(request).await;
1871 if let Some(request_id) = request_id {
1872 assert!(!output.is_empty(), "{method} returned no response");
1873 assert_eq!(output[0]["id"], request_id, "{method} changed request id");
1874 assert!(
1875 output[0].get("error").is_none(),
1876 "{method} failed corpus replay: {}",
1877 output[0]
1878 );
1879 }
1880 if method == "thread/start" {
1881 thread_id = Some(output[0]["result"]["thread"]["id"].as_str().unwrap().into());
1882 }
1883 replayed += 1;
1884 }
1885
1886 assert_eq!(replayed, 24, "pinned corpus request count drifted");
1887 }
1888
1889 #[tokio::test]
1890 async fn adapter_preserves_the_pinned_stock_notification_order() {
1891 const CORPUS: &str = include_str!(
1892 "../../../scripts/client-protocol-corpus/fixtures/codex_app_server_v0_144.jsonl"
1893 );
1894 let expected = CORPUS
1895 .lines()
1896 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
1897 .filter(|envelope| envelope["direction"] == "server_to_client")
1898 .filter_map(|envelope| envelope["message"]["method"].as_str().map(str::to_owned))
1899 .collect::<BTreeSet<_>>();
1900
1901 let adapter = CodexAppServerAdapter::new(RecordingRuntime::new(), "/workspace");
1902 let mut connection = adapter.connection();
1903 let mut output = connection
1904 .handle(
1905 json!({"id":1,"method":"initialize","params":{"clientInfo":{"version":"0.144.4"}}}),
1906 )
1907 .await;
1908 connection.handle(json!({"method":"initialized"})).await;
1909 output.extend(
1910 connection
1911 .handle(json!({"id":2,"method":"thread/start","params":{}}))
1912 .await,
1913 );
1914 let events = [
1915 (3, "turn_started", json!({"type":"turn_started"})),
1916 (
1917 4,
1918 "user_message",
1919 json!({"type":"user_message","text":"prompt"}),
1920 ),
1921 (
1922 5,
1923 "reasoning_delta",
1924 json!({"type":"reasoning_delta","text":"reason"}),
1925 ),
1926 (
1927 6,
1928 "reasoning_completed",
1929 json!({"type":"reasoning_completed"}),
1930 ),
1931 (
1932 7,
1933 "text_delta",
1934 json!({"type":"text_delta","text":"answer"}),
1935 ),
1936 (
1937 8,
1938 "diff_updated",
1939 json!({"type":"diff_updated","diff":"@@"}),
1940 ),
1941 (
1942 9,
1943 "usage",
1944 json!({"type":"usage","prompt_tokens":1,"completion_tokens":1,"total_tokens":2}),
1945 ),
1946 (
1947 10,
1948 "cache_warning",
1949 json!({"type":"cache_warning","message":"warning"}),
1950 ),
1951 (
1952 11,
1953 "request",
1954 json!({"type":"request","request":{"id":77,"kind":"approval","payload":{"command":"true"}}}),
1955 ),
1956 (
1957 12,
1958 "request_resolved",
1959 json!({"type":"request_resolved","request_id":77}),
1960 ),
1961 (13, "turn_completed", json!({"type":"turn_completed"})),
1962 (
1963 14,
1964 "turn_failed",
1965 json!({"type":"turn_failed","message":"failure"}),
1966 ),
1967 ];
1968 for (sequence, kind, payload) in events {
1969 output.extend(
1970 connection
1971 .event_notifications(FrontendEvent {
1972 sequence,
1973 kind: kind.into(),
1974 payload,
1975 })
1976 .unwrap(),
1977 );
1978 }
1979 let actual = output
1980 .iter()
1981 .filter_map(|message| message["method"].as_str().map(str::to_owned))
1982 .collect::<Vec<_>>();
1983 let actual_families = actual.iter().cloned().collect::<BTreeSet<_>>();
1984 let missing = expected
1985 .difference(&actual_families)
1986 .cloned()
1987 .collect::<Vec<_>>();
1988 assert!(
1989 missing.is_empty(),
1990 "missing notification families: {missing:?}"
1991 );
1992 assert_eq!(
1993 actual,
1994 [
1995 "remoteControl/status/changed",
1996 "thread/started",
1997 "thread/settings/updated",
1998 "account/rateLimits/updated",
1999 "thread/goal/cleared",
2000 "thread/status/changed",
2001 "turn/started",
2002 "item/started",
2003 "item/completed",
2004 "item/started",
2005 "item/reasoning/summaryPartAdded",
2006 "item/reasoning/summaryTextDelta",
2007 "item/completed",
2008 "item/started",
2009 "item/agentMessage/delta",
2010 "turn/diff/updated",
2011 "thread/tokenUsage/updated",
2012 "warning",
2013 "item/commandExecution/requestApproval",
2014 "serverRequest/resolved",
2015 "error",
2016 "item/completed",
2017 "thread/status/changed",
2018 "turn/completed",
2019 ],
2020 "semantic notification order drifted from the pinned stock-client cadence"
2021 );
2022 }
2023}