1pub use kcode_k1_codex_conversations::{
2 Config, DynamicTool, Error, ErrorKind, Event, ToolCall, ToolResult,
3};
4use kcode_k1_codex_conversations::{
5 Diagnostics, Pending, State, ToolToken, parse_rpc_id, thread_start_params, turn_start_params,
6};
7use kcode_k1_codex_events::{
8 DynamicToolCall as DecodedToolCall, Inbound, ResponseOutcome, RpcId, ScopedEvent, ScopedKind,
9 ServerError, TurnCompleted, TurnStatus, decode, rejection_payload,
10};
11use kcode_k1_codex_transport::{
12 ClientInfo, Config as WireConfig, Error as WireError, ErrorKind as WireKind, Transport,
13};
14use serde_json::{Value, json};
15use std::{
16 collections::HashMap,
17 fmt,
18 sync::{Arc, Weak},
19 time::Duration,
20};
21use tokio::sync::{mpsc, oneshot};
22
23const EARLY_LIMIT: usize = 1024;
24type Events = mpsc::UnboundedSender<Event>;
25type Reply = oneshot::Sender<Result<(), Error>>;
26type CloseReply = Option<Reply>;
27type StartReply = oneshot::Sender<Result<u64, Error>>;
28
29enum Command {
30 Start {
31 key: String,
32 input: String,
33 profile: Arc<Config>,
34 events: Events,
35 reply: StartReply,
36 },
37 Respond {
38 key: String,
39 turn: u64,
40 call: String,
41 result: ToolResult,
42 reply: Reply,
43 },
44 Abandon {
45 key: String,
46 turn: u64,
47 },
48 Close {
49 key: String,
50 profile: Arc<Config>,
51 reply: Reply,
52 },
53}
54
55struct Client {
56 commands: mpsc::UnboundedSender<Command>,
57 diagnostics: Diagnostics,
58}
59
60#[derive(Clone)]
61pub struct Adapter {
62 client: Arc<Client>,
63 profile: Arc<Config>,
64}
65
66impl Adapter {
67 pub async fn open(config: Config) -> Result<Self, Error> {
68 open(config).await
69 }
70
71 pub fn with_config(&self, config: Config) -> Result<Adapter, Error> {
72 if let Err(mut error) = config.validate() {
73 error.diagnostics = self.client.diagnostics.snapshot();
74 return Err(error);
75 }
76 if config.executable != self.profile.executable
77 || config.working_directory != self.profile.working_directory
78 {
79 return Err(self.client.diagnostics.error(
80 ErrorKind::Protocol,
81 "derived configuration must use the same executable and working directory",
82 ));
83 }
84 Ok(Adapter {
85 client: Arc::clone(&self.client),
86 profile: Arc::new(config),
87 })
88 }
89
90 pub async fn start_turn(
91 &self,
92 conversation_key: impl Into<String>,
93 input: impl Into<String>,
94 ) -> Result<Turn, Error> {
95 let key = conversation_key.into();
96 let (events, receiver) = mpsc::unbounded_channel();
97 let (reply, answer) = oneshot::channel();
98 self.client
99 .commands
100 .send(Command::Start {
101 key: key.clone(),
102 input: input.into(),
103 profile: Arc::clone(&self.profile),
104 events,
105 reply,
106 })
107 .map_err(|_| self.unavailable())?;
108 let serial = answer.await.map_err(|_| self.unavailable())??;
109 Ok(Turn {
110 key,
111 serial,
112 client: Arc::downgrade(&self.client),
113 events: receiver,
114 diagnostics: self.client.diagnostics.clone(),
115 terminal: false,
116 })
117 }
118
119 pub async fn close_conversation(&self, key: impl Into<String>) -> Result<(), Error> {
120 let (reply, answer) = oneshot::channel();
121 self.client
122 .commands
123 .send(Command::Close {
124 key: key.into(),
125 profile: Arc::clone(&self.profile),
126 reply,
127 })
128 .map_err(|_| self.unavailable())?;
129 answer.await.map_err(|_| self.unavailable())?
130 }
131
132 pub fn diagnostics(&self) -> Vec<u8> {
133 self.client.diagnostics.snapshot()
134 }
135
136 fn unavailable(&self) -> Error {
137 self.client
138 .diagnostics
139 .error(ErrorKind::Unavailable, "Codex app-server is unavailable")
140 }
141}
142
143pub struct Turn {
144 key: String,
145 serial: u64,
146 client: Weak<Client>,
147 events: mpsc::UnboundedReceiver<Event>,
148 diagnostics: Diagnostics,
149 terminal: bool,
150}
151
152impl fmt::Debug for Turn {
153 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154 formatter
155 .debug_struct("Turn")
156 .field("key", &self.key)
157 .field("serial", &self.serial)
158 .finish_non_exhaustive()
159 }
160}
161
162impl Turn {
163 pub async fn next_event(&mut self) -> Option<Event> {
164 let event = self.events.recv().await;
165 if event
166 .as_ref()
167 .is_none_or(|event| matches!(event, Event::Done | Event::Error(_)))
168 {
169 self.terminal = true;
170 }
171 event
172 }
173
174 pub fn try_next_event(&mut self) -> Result<Option<Event>, Error> {
175 match self.events.try_recv() {
176 Ok(event) => {
177 if matches!(event, Event::Done | Event::Error(_)) {
178 self.terminal = true;
179 }
180 Ok(Some(event))
181 }
182 Err(mpsc::error::TryRecvError::Empty) => Ok(None),
183 Err(mpsc::error::TryRecvError::Disconnected) => {
184 self.terminal = true;
185 Err(self.unavailable())
186 }
187 }
188 }
189
190 pub async fn respond(
191 &self,
192 call_id: impl Into<String>,
193 result: ToolResult,
194 ) -> Result<(), Error> {
195 let client = self.client.upgrade().ok_or_else(|| self.unavailable())?;
196 let (reply, answer) = oneshot::channel();
197 client
198 .commands
199 .send(Command::Respond {
200 key: self.key.clone(),
201 turn: self.serial,
202 call: call_id.into(),
203 result,
204 reply,
205 })
206 .map_err(|_| self.unavailable())?;
207 answer.await.map_err(|_| self.unavailable())?
208 }
209
210 fn unavailable(&self) -> Error {
211 self.diagnostics
212 .error(ErrorKind::Unavailable, "Codex app-server is unavailable")
213 }
214}
215
216impl Drop for Turn {
217 fn drop(&mut self) {
218 if !self.terminal
219 && let Some(client) = self.client.upgrade()
220 {
221 let _ = client.commands.send(Command::Abandon {
222 key: self.key.clone(),
223 turn: self.serial,
224 });
225 }
226 }
227}
228
229struct Actor {
230 transport: Transport,
231 commands: mpsc::UnboundedReceiver<Command>,
232 profiles: HashMap<String, Arc<Config>>,
233 diagnostics: Diagnostics,
234 state: State<Events, CloseReply>,
235}
236
237async fn open(config: Config) -> Result<Adapter, Error> {
238 config.validate()?;
239 let mut wire = WireConfig::new(config.executable.clone(), config.working_directory.clone());
240 wire.reasoning_effort = config.reasoning_effort.clone();
241 wire.client = ClientInfo {
242 name: "kcode-k1-codex-adapter".into(),
243 title: "K1 Codex Adapter".into(),
244 version: "0.2.1".into(),
245 };
246 let transport = Transport::open(wire).await.map_err(map_wire)?;
247 let profile = Arc::new(config);
248 let diagnostics = Diagnostics::new(transport.diagnostics());
249 let (commands, receiver) = mpsc::unbounded_channel();
250 let client = Arc::new(Client {
251 commands,
252 diagnostics: diagnostics.clone(),
253 });
254 let mut actor = Actor {
255 transport,
256 commands: receiver,
257 profiles: HashMap::new(),
258 diagnostics,
259 state: State::default(),
260 };
261 tokio::spawn(async move {
262 let outcome = actor.run().await;
263 actor.refresh();
264 let mut error = outcome
265 .err()
266 .unwrap_or_else(|| actor.error(ErrorKind::Unavailable, "Codex app-server was closed"));
267 let _ = actor.reject_all("adapter stopped").await;
268 let sinks = actor.state.take_sinks();
269 actor.refresh();
270 error.diagnostics = actor.diagnostics.snapshot();
271 for sink in sinks {
272 let _ = sink.send(Event::Error(error.clone()));
273 }
274 let _ = actor.transport.close().await;
275 actor.refresh();
276 });
277 Ok(Adapter { client, profile })
278}
279
280impl Actor {
281 async fn run(&mut self) -> Result<(), Error> {
282 let mut tick = tokio::time::interval(Duration::from_millis(20));
283 loop {
284 tokio::select! {
285 command = self.commands.recv() => match command {
286 Some(command) => self.command(command).await?,
287 None => return Ok(()),
288 },
289 message = self.transport.receive() => self.message(message.map_err(map_wire)?).await?,
290 _ = tick.tick() => self.refresh(),
291 }
292 }
293 }
294
295 async fn command(&mut self, command: Command) -> Result<(), Error> {
296 self.refresh();
297 match command {
298 Command::Start {
299 key,
300 input,
301 profile,
302 events,
303 reply,
304 } => {
305 if self.profile_conflict(&key, &profile) {
306 let _ = reply.send(Err(self.error(
307 ErrorKind::Protocol,
308 "conversation key is bound to a different configuration profile",
309 )));
310 return Ok(());
311 }
312 if self.start_pending(&key) {
313 let _ = reply.send(Err(
314 self.error(ErrorKind::Busy, "conversation already has an active turn")
315 ));
316 return Ok(());
317 }
318 let serial = match self.state.begin_turn(key.clone(), events) {
319 Ok(serial) => serial,
320 Err(error) => {
321 let _ = reply.send(Err(self.stamp(error)));
322 return Ok(());
323 }
324 };
325 let profile = if let Some(bound) = self.profiles.get(&key) {
326 Arc::clone(bound)
327 } else {
328 self.profiles.insert(key.clone(), Arc::clone(&profile));
329 profile
330 };
331 let request = if let Some(thread) = self.state.thread(&key).map(str::to_owned) {
332 self.request(
333 "turn/start",
334 profiled_turn_start_params(&thread, input, &profile),
335 Pending::Turn {
336 key: key.clone(),
337 serial,
338 },
339 )
340 .await
341 } else {
342 self.request(
343 "thread/start",
344 thread_start_params(&profile),
345 Pending::Thread {
346 key: key.clone(),
347 serial,
348 input,
349 },
350 )
351 .await
352 };
353 if let Err(error) = request {
354 self.state.take_active(&key, serial);
355 let _ = reply.send(Err(error.clone()));
356 return Err(error);
357 }
358 if reply.send(Ok(serial)).is_err()
359 && let Some(active) = self.state.take_active(&key, serial)
360 {
361 self.reject_early(active.early, "turn start receiver was dropped")
362 .await?;
363 }
364 }
365 Command::Respond {
366 key,
367 turn,
368 call,
369 result,
370 reply,
371 } => {
372 let token: ToolToken = (key, turn, call);
373 let Some(pending) = self.state.take_tool(&token) else {
374 let _ = reply.send(Err(self.error(
375 ErrorKind::InvalidToolResult,
376 "tool call is not pending on this turn",
377 )));
378 return Ok(());
379 };
380 let message = json!({
381 "id": pending.id,
382 "result": {
383 "contentItems": [{"type": "inputText", "text": result.output}],
384 "success": result.success
385 }
386 });
387 if let Err(error) = self.transport.send(&message).await.map_err(map_wire) {
388 let _ = reply.send(Err(error.clone()));
389 return Err(error);
390 }
391 let _ = reply.send(Ok(()));
392 }
393 Command::Abandon { key, turn } => self.abandon(&key, turn).await?,
394 Command::Close {
395 key,
396 profile,
397 reply,
398 } => {
399 if self.profile_conflict(&key, &profile) {
400 let _ = reply.send(Err(self.error(
401 ErrorKind::Protocol,
402 "conversation key is bound to a different configuration profile",
403 )));
404 return Ok(());
405 }
406 if self.start_pending(&key) {
407 let _ = reply.send(Err(
408 self.error(ErrorKind::Busy, "conversation is active or already closing")
409 ));
410 return Ok(());
411 }
412 let thread = match self.state.begin_close(&key) {
413 Ok(Some(thread)) => thread,
414 Ok(None) => {
415 self.profiles.remove(&key);
416 let _ = reply.send(Ok(()));
417 return Ok(());
418 }
419 Err(error) => {
420 let _ = reply.send(Err(self.stamp(error)));
421 return Ok(());
422 }
423 };
424 if let Err(error) = self
425 .request(
426 "thread/unsubscribe",
427 json!({"threadId": thread}),
428 Pending::Close {
429 key: key.clone(),
430 thread,
431 reply: Some(reply),
432 },
433 )
434 .await
435 {
436 self.state.cancel_close(&key);
437 return Err(error);
438 }
439 }
440 }
441 Ok(())
442 }
443
444 async fn message(&mut self, message: Value) -> Result<(), Error> {
445 self.refresh();
446 match decode(message.clone()) {
447 Inbound::ClientResponse(response) => self.response(response.id, response.outcome).await,
448 Inbound::Scoped(event) => self.scoped(event, message).await,
449 Inbound::ResolvedRequest(id) => {
450 self.state
451 .resolve_tool(&id.as_value())
452 .map_err(|error| self.stamp(error))?;
453 Ok(())
454 }
455 Inbound::GlobalServerError(error) => Err(error.to_error(self.transport.diagnostics())),
456 Inbound::ModelReroute(reroute) => {
457 if let Some(id) = reroute.id {
458 self.reject(&id, -32602, "model rerouting is unsupported")
459 .await?;
460 }
461 Err(self.error(ErrorKind::Protocol, "model rerouting is unsupported"))
462 }
463 Inbound::UnsupportedServerRequest(request) => {
464 self.reject(&request.id, -32601, "unsupported server request")
465 .await
466 }
467 Inbound::IgnoredNotification { .. } => Ok(()),
468 Inbound::Malformed(error) => self.malformed(&message, error).await,
469 }
470 }
471
472 async fn malformed(&mut self, message: &Value, error: Error) -> Result<(), Error> {
473 if message.get("method").and_then(Value::as_str) == Some("item/tool/call")
474 && let Some(id) = message.get("id")
475 && let Ok(id) = RpcId::try_from(id)
476 {
477 self.reject(&id, -32602, "malformed dynamic tool request")
478 .await?;
479 }
480 Err(self.stamp(error))
481 }
482
483 async fn response(&mut self, id: u64, outcome: ResponseOutcome) -> Result<(), Error> {
484 let pending = self
485 .state
486 .take_pending(id)
487 .map_err(|error| self.stamp(error))?;
488 let result = match outcome {
489 ResponseOutcome::Result(result) => result,
490 ResponseOutcome::Error(error) => {
491 return self
492 .pending_failed(pending, error.to_error(self.transport.diagnostics()))
493 .await;
494 }
495 };
496 match pending {
497 Pending::Thread { key, serial, input } => {
498 let Some(thread) = result
499 .pointer("/thread/id")
500 .and_then(Value::as_str)
501 .map(str::to_owned)
502 else {
503 let error = self.error(
504 ErrorKind::Protocol,
505 "thread/start response omitted thread.id",
506 );
507 return self.prestart_fail(&key, serial, error).await;
508 };
509 let profile = self.profiles.get(&key).cloned().ok_or_else(|| {
510 self.error(
511 ErrorKind::Protocol,
512 "conversation has no configuration profile",
513 )
514 })?;
515 let live = match self
516 .state
517 .conversations
518 .get(&key)
519 .and_then(|conversation| conversation.active.as_ref())
520 {
521 Some(active) if active.serial == serial => !active.cancelled,
522 Some(_) => {
523 return Err(self.error(
524 ErrorKind::Protocol,
525 "thread/start response crossed conversation turns",
526 ));
527 }
528 None => false,
529 };
530 if let Err(error) = self.state.set_thread(&key, thread.clone()) {
531 let error = self.stamp(error);
532 return self.prestart_fail(&key, serial, error).await;
533 }
534 if !live {
535 if let Some(active) = self.state.take_active(&key, serial) {
536 self.reject_early(active.early, "turn was abandoned")
537 .await?;
538 }
539 return self.retire_thread(&key).await;
540 }
541 self.request(
542 "turn/start",
543 profiled_turn_start_params(&thread, input, &profile),
544 Pending::Turn { key, serial },
545 )
546 .await?;
547 }
548 Pending::Turn { key, serial } => {
549 let Some(turn) = result
550 .pointer("/turn/id")
551 .and_then(Value::as_str)
552 .map(str::to_owned)
553 else {
554 let error =
555 self.error(ErrorKind::Protocol, "turn/start response omitted turn.id");
556 return self.prestart_fail(&key, serial, error).await;
557 };
558 let Some(early) = self.state.set_native_turn(&key, serial, turn.clone()) else {
559 return self.retire_turn(&key, &turn).await;
560 };
561 for message in early {
562 match decode(message.clone()) {
563 Inbound::Scoped(event) => self.scoped(event, message).await?,
564 Inbound::Malformed(error) => return Err(self.stamp(error)),
565 _ => {
566 return Err(self.error(
567 ErrorKind::Protocol,
568 "buffered event changed classification",
569 ));
570 }
571 }
572 }
573 self.interrupt(&key, serial).await?;
574 }
575 Pending::Close { key, thread, reply } => {
576 if let Err(error) = self.state.finish_close(&key, &thread) {
577 let error = self.stamp(error);
578 if let Some(reply) = reply {
579 let _ = reply.send(Err(error.clone()));
580 }
581 return Err(error);
582 }
583 self.profiles.remove(&key);
584 if let Some(reply) = reply {
585 let _ = reply.send(Ok(()));
586 }
587 }
588 Pending::Interrupt => {}
589 }
590 Ok(())
591 }
592
593 async fn pending_failed(
594 &mut self,
595 pending: Pending<CloseReply>,
596 error: Error,
597 ) -> Result<(), Error> {
598 match pending {
599 Pending::Thread { key, serial, .. } | Pending::Turn { key, serial } => {
600 self.prestart_fail(&key, serial, error).await
601 }
602 Pending::Close { key, reply, .. } => {
603 self.state.cancel_close(&key);
604 if let Some(reply) = reply {
605 let _ = reply.send(Err(error));
606 }
607 Ok(())
608 }
609 Pending::Interrupt => Err(error),
610 }
611 }
612
613 async fn scoped(&mut self, event: ScopedEvent, message: Value) -> Result<(), Error> {
614 self.refresh();
615 let ScopedEvent { scope, kind } = event;
616 let Some(key) = self.state.owner(&scope.thread_id).map(str::to_owned) else {
617 self.reject_if_tool(&kind, "unknown thread").await?;
618 return Ok(());
619 };
620 let Some((serial, current, cancelled)) = self
621 .state
622 .conversations
623 .get(&key)
624 .and_then(|conversation| conversation.active.as_ref())
625 .map(|active| (active.serial, active.turn.clone(), active.cancelled))
626 else {
627 self.reject_if_tool(&kind, "no active turn").await?;
628 return Ok(());
629 };
630 let Some(current) = current else {
631 if cancelled && !matches!(&kind, ScopedKind::TurnCompleted(_) | ScopedKind::Error(_)) {
632 self.reject_if_tool(&kind, "turn was abandoned").await?;
633 return Ok(());
634 }
635 let active = self
636 .state
637 .conversations
638 .get_mut(&key)
639 .and_then(|conversation| conversation.active.as_mut())
640 .expect("active turn exists");
641 if active.early.len() >= EARLY_LIMIT {
642 self.reject_if_tool(&kind, "early event limit exceeded")
643 .await?;
644 return Err(self.error(ErrorKind::Protocol, "early event limit exceeded"));
645 }
646 active.early.push(message);
647 return Ok(());
648 };
649 if current != scope.turn_id {
650 self.reject_if_tool(&kind, "request is outside the active turn")
651 .await?;
652 return Ok(());
653 }
654 if cancelled && !matches!(&kind, ScopedKind::TurnCompleted(_)) {
655 self.reject_if_tool(&kind, "turn was abandoned").await?;
656 return Ok(());
657 }
658 self.dispatch(key, serial, kind).await
659 }
660
661 async fn dispatch(&mut self, key: String, serial: u64, kind: ScopedKind) -> Result<(), Error> {
662 match kind {
663 ScopedKind::AgentTextDelta(delta) => {
664 if !self.emit(&key, serial, Event::TextDelta(delta)) {
665 self.abandon(&key, serial).await?;
666 }
667 }
668 ScopedKind::DynamicToolCall(call) => self.tool_call(&key, serial, call).await?,
669 ScopedKind::TurnCompleted(completed) => self.complete(&key, serial, completed).await?,
670 ScopedKind::Error(error) => {
671 if let Some(active) = self
672 .state
673 .conversations
674 .get_mut(&key)
675 .and_then(|conversation| conversation.active.as_mut())
676 .filter(|active| active.serial == serial)
677 {
678 active.failure = Some(error.details);
679 }
680 }
681 ScopedKind::TurnStarted => {}
682 }
683 Ok(())
684 }
685
686 async fn tool_call(
687 &mut self,
688 key: &str,
689 serial: u64,
690 call: DecodedToolCall,
691 ) -> Result<(), Error> {
692 let configured = self
693 .profiles
694 .get(key)
695 .is_some_and(|profile| profile.tools.iter().any(|tool| tool.name == call.name));
696 if !configured {
697 self.reject(&call.rpc_id, -32602, "unconfigured dynamic tool")
698 .await?;
699 return self
700 .fail_turn(
701 key,
702 serial,
703 self.error(ErrorKind::Protocol, "unconfigured dynamic tool requested"),
704 )
705 .await;
706 }
707 let id = call.rpc_id.as_value();
708 if let Err(error) = self
709 .state
710 .track_tool(key, serial, call.call_id.clone(), &id)
711 {
712 let error = self.stamp(error);
713 if error.message != "duplicate dynamic tool call id" {
714 return Err(error);
715 }
716 self.reject(&call.rpc_id, -32602, &error.message).await?;
717 return self.fail_turn(key, serial, error).await;
718 }
719 if !self.emit(
720 key,
721 serial,
722 Event::ToolCall(ToolCall {
723 call_id: call.call_id,
724 name: call.name,
725 arguments: call.arguments,
726 }),
727 ) {
728 self.abandon(key, serial).await?;
729 }
730 Ok(())
731 }
732
733 async fn complete(
734 &mut self,
735 key: &str,
736 serial: u64,
737 completed: TurnCompleted,
738 ) -> Result<(), Error> {
739 let pending = self
740 .state
741 .tools
742 .keys()
743 .filter(|(owner, turn, _)| owner == key && *turn == serial)
744 .count();
745 if pending != 0 {
746 self.reject_tools(key, serial, "turn completed before tool results")
747 .await?;
748 }
749 let Some(mut active) = self.state.take_active(key, serial) else {
750 return Ok(());
751 };
752 if active.cancelled {
753 return Ok(());
754 }
755 let failure = completed
756 .failure
757 .or_else(|| active.failure.take().map(ServerError::new));
758 let event = match completed.status {
759 TurnStatus::Completed if pending == 0 => Event::Done,
760 TurnStatus::Completed => Event::Error(self.error(
761 ErrorKind::Protocol,
762 "turn completed with pending tool calls",
763 )),
764 TurnStatus::Interrupted => {
765 Event::Error(self.error(ErrorKind::Interrupted, "Codex turn was interrupted"))
766 }
767 TurnStatus::Failed => Event::Error(
768 failure
769 .unwrap_or_else(|| ServerError::new(Value::Null))
770 .to_error(self.transport.diagnostics()),
771 ),
772 };
773 if let Some(events) = active.sink {
774 let _ = events.send(event);
775 }
776 Ok(())
777 }
778
779 async fn prestart_fail(&mut self, key: &str, serial: u64, error: Error) -> Result<(), Error> {
780 if let Some(active) = self.state.take_active(key, serial) {
781 if let Some(events) = active.sink {
782 let _ = events.send(Event::Error(error));
783 }
784 self.reject_early(active.early, "turn failed to start")
785 .await?;
786 }
787 Ok(())
788 }
789
790 async fn fail_turn(&mut self, key: &str, serial: u64, error: Error) -> Result<(), Error> {
791 if let Some(active) = self
792 .state
793 .conversations
794 .get_mut(key)
795 .and_then(|conversation| conversation.active.as_mut())
796 .filter(|active| active.serial == serial)
797 {
798 if let Some(events) = active.sink.take() {
799 let _ = events.send(Event::Error(error));
800 }
801 active.cancelled = true;
802 }
803 self.reject_tools(key, serial, "turn failed").await?;
804 self.interrupt(key, serial).await
805 }
806
807 async fn abandon(&mut self, key: &str, serial: u64) -> Result<(), Error> {
808 if let Some(active) = self
809 .state
810 .conversations
811 .get_mut(key)
812 .and_then(|conversation| conversation.active.as_mut())
813 .filter(|active| active.serial == serial)
814 {
815 active.sink.take();
816 active.cancelled = true;
817 }
818 self.reject_tools(key, serial, "turn was abandoned").await?;
819 self.interrupt(key, serial).await
820 }
821
822 async fn interrupt(&mut self, key: &str, serial: u64) -> Result<(), Error> {
823 let cancelled = self
824 .state
825 .conversations
826 .get(key)
827 .and_then(|conversation| conversation.active.as_ref())
828 .is_some_and(|active| active.serial == serial && active.cancelled);
829 if cancelled && let Some((thread, turn)) = self.state.interrupt_target(key, serial) {
830 self.request(
831 "turn/interrupt",
832 json!({"threadId": thread, "turnId": turn}),
833 Pending::Interrupt,
834 )
835 .await?;
836 }
837 Ok(())
838 }
839
840 async fn retire_turn(&mut self, key: &str, turn: &str) -> Result<(), Error> {
841 let thread = self
842 .state
843 .thread(key)
844 .ok_or_else(|| {
845 self.error(
846 ErrorKind::Protocol,
847 "orphan turn had no conversation thread",
848 )
849 })?
850 .to_owned();
851 self.request(
852 "turn/interrupt",
853 json!({"threadId": thread, "turnId": turn}),
854 Pending::Interrupt,
855 )
856 .await?;
857 self.retire_thread(key).await
858 }
859
860 async fn retire_thread(&mut self, key: &str) -> Result<(), Error> {
861 let thread = match self.state.begin_close(key) {
862 Ok(Some(thread)) => thread,
863 Ok(None) => {
864 self.profiles.remove(key);
865 return Ok(());
866 }
867 Err(error) => return Err(self.stamp(error)),
868 };
869 self.request(
870 "thread/unsubscribe",
871 json!({"threadId": thread}),
872 Pending::Close {
873 key: key.to_owned(),
874 thread,
875 reply: None,
876 },
877 )
878 .await
879 }
880
881 async fn reject_early(&mut self, early: Vec<Value>, reason: &str) -> Result<(), Error> {
882 for message in early {
883 match decode(message) {
884 Inbound::Scoped(event) => self.reject_if_tool(&event.kind, reason).await?,
885 Inbound::Malformed(error) => return Err(self.stamp(error)),
886 _ => {
887 return Err(
888 self.error(ErrorKind::Protocol, "buffered event changed classification")
889 );
890 }
891 }
892 }
893 Ok(())
894 }
895
896 async fn reject_tools(&mut self, key: &str, serial: u64, reason: &str) -> Result<(), Error> {
897 for (_, pending) in self.state.take_turn_tools(key, serial) {
898 let id = RpcId::try_from(&pending.id).map_err(|error| self.stamp(error))?;
899 self.reject(&id, -32800, reason).await?;
900 }
901 Ok(())
902 }
903
904 async fn reject_all(&mut self, reason: &str) -> Result<(), Error> {
905 let tokens: Vec<_> = self.state.tools.keys().cloned().collect();
906 for token in tokens {
907 if let Some(pending) = self.state.take_tool(&token) {
908 let id = RpcId::try_from(&pending.id).map_err(|error| self.stamp(error))?;
909 self.reject(&id, -32800, reason).await?;
910 }
911 }
912 Ok(())
913 }
914
915 async fn reject_if_tool(&mut self, kind: &ScopedKind, reason: &str) -> Result<(), Error> {
916 if let ScopedKind::DynamicToolCall(call) = kind {
917 self.reject(&call.rpc_id, -32602, reason).await?;
918 }
919 Ok(())
920 }
921
922 async fn reject(&mut self, id: &RpcId, code: i64, reason: &str) -> Result<(), Error> {
923 let (_, key) = parse_rpc_id(&id.as_value()).map_err(|error| self.stamp(error))?;
924 if self.state.rpc_ids.contains(&key) {
925 return Err(self.error(ErrorKind::Protocol, "duplicate app-server request id"));
926 }
927 self.transport
928 .send(&rejection_payload(id, code, reason))
929 .await
930 .map_err(map_wire)
931 }
932
933 async fn request(
934 &mut self,
935 method: &str,
936 params: Value,
937 pending: Pending<CloseReply>,
938 ) -> Result<(), Error> {
939 let id = self
940 .state
941 .allocate_request_id()
942 .map_err(|error| self.stamp(error))?;
943 self.transport
944 .send(&json!({"method": method, "id": id, "params": params}))
945 .await
946 .map_err(map_wire)?;
947 self.state
948 .insert_pending(id, pending)
949 .map_err(|error| self.stamp(error))
950 }
951
952 fn profile_conflict(&self, key: &str, profile: &Config) -> bool {
953 self.profiles
954 .get(key)
955 .is_some_and(|bound| !profiles_equivalent(bound, profile))
956 }
957
958 fn start_pending(&self, key: &str) -> bool {
959 self.state.pending.values().any(|pending| {
960 matches!(
961 pending,
962 Pending::Thread { key: owner, .. }
963 | Pending::Turn { key: owner, .. }
964 if owner == key
965 )
966 })
967 }
968
969 fn emit(&mut self, key: &str, serial: u64, event: Event) -> bool {
970 self.state
971 .conversations
972 .get_mut(key)
973 .and_then(|conversation| conversation.active.as_mut())
974 .filter(|active| active.serial == serial)
975 .and_then(|active| active.sink.as_ref())
976 .is_some_and(|events| events.send(event).is_ok())
977 }
978
979 fn error(&self, kind: ErrorKind, message: impl Into<String>) -> Error {
980 Error {
981 kind,
982 message: message.into(),
983 diagnostics: self.transport.diagnostics(),
984 }
985 }
986
987 fn stamp(&self, mut error: Error) -> Error {
988 error.diagnostics = self.transport.diagnostics();
989 error
990 }
991
992 fn refresh(&self) {
993 self.diagnostics.replace(self.transport.diagnostics());
994 }
995}
996
997fn profiles_equivalent(left: &Config, right: &Config) -> bool {
998 left.executable == right.executable
999 && left.working_directory == right.working_directory
1000 && left.model == right.model
1001 && left.reasoning_effort == right.reasoning_effort
1002 && left.base_instructions == right.base_instructions
1003 && left.tools.len() == right.tools.len()
1004 && left.tools.iter().zip(&right.tools).all(|(left, right)| {
1005 left.name == right.name
1006 && left.description == right.description
1007 && left.input_schema == right.input_schema
1008 })
1009}
1010
1011fn profiled_turn_start_params(thread: &str, input: String, profile: &Config) -> Value {
1012 let mut params = turn_start_params(thread, input);
1013 let object = params
1014 .as_object_mut()
1015 .expect("turn_start_params must return an object");
1016 object.insert("model".into(), Value::String(profile.model.clone()));
1017 if let Some(effort) = &profile.reasoning_effort {
1018 object.insert("effort".into(), Value::String(effort.clone()));
1019 } else {
1020 object.remove("effort");
1021 }
1022 params
1023}
1024
1025fn map_wire(error: WireError) -> Error {
1026 let kind = match error.kind {
1027 WireKind::Protocol => ErrorKind::Protocol,
1028 WireKind::Server => ErrorKind::Server,
1029 WireKind::Spawn | WireKind::Timeout | WireKind::Io | WireKind::Closed => {
1030 ErrorKind::Unavailable
1031 }
1032 };
1033 Error {
1034 kind,
1035 message: error.message,
1036 diagnostics: error.diagnostics,
1037 }
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 use super::*;
1043
1044 fn profile() -> Config {
1045 Config {
1046 executable: "codex".into(),
1047 working_directory: "/workspace".into(),
1048 model: "model-a".into(),
1049 reasoning_effort: Some("high".into()),
1050 base_instructions: "base instructions".into(),
1051 tools: vec![DynamicTool {
1052 name: "lookup".into(),
1053 description: "Look up a value".into(),
1054 input_schema: json!({
1055 "type": "object",
1056 "properties": {
1057 "key": {"type": "string"}
1058 },
1059 "required": ["key"]
1060 }),
1061 }],
1062 }
1063 }
1064
1065 #[test]
1066 fn full_profiles_compare_by_value() {
1067 let original = profile();
1068 assert!(profiles_equivalent(&original, &profile()));
1069
1070 let mut different = profile();
1071 different.executable = "other-codex".into();
1072 assert!(!profiles_equivalent(&original, &different));
1073
1074 let mut different = profile();
1075 different.working_directory = "/other-workspace".into();
1076 assert!(!profiles_equivalent(&original, &different));
1077
1078 let mut different = profile();
1079 different.model = "model-b".into();
1080 assert!(!profiles_equivalent(&original, &different));
1081
1082 let mut different = profile();
1083 different.reasoning_effort = None;
1084 assert!(!profiles_equivalent(&original, &different));
1085
1086 let mut different = profile();
1087 different.base_instructions = "different instructions".into();
1088 assert!(!profiles_equivalent(&original, &different));
1089
1090 let mut different = profile();
1091 different.tools.clear();
1092 assert!(!profiles_equivalent(&original, &different));
1093 }
1094
1095 #[test]
1096 fn turn_start_adds_model_and_optional_effort() {
1097 let configured = profile();
1098 let actual = profiled_turn_start_params("thread-1", "hello".into(), &configured);
1099 let mut expected = turn_start_params("thread-1", "hello");
1100 let object = expected.as_object_mut().unwrap();
1101 object.insert("model".into(), json!("model-a"));
1102 object.insert("effort".into(), json!("high"));
1103 assert_eq!(actual, expected);
1104
1105 let mut without_effort = profile();
1106 without_effort.reasoning_effort = None;
1107 let actual = profiled_turn_start_params("thread-1", "hello".into(), &without_effort);
1108 let mut expected = turn_start_params("thread-1", "hello");
1109 expected
1110 .as_object_mut()
1111 .unwrap()
1112 .insert("model".into(), json!("model-a"));
1113 assert_eq!(actual, expected);
1114 assert!(actual.get("effort").is_none());
1115 }
1116}