Skip to main content

kcode_k1_codex_runtime/
lib.rs

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}