1use std::sync::Arc;
28
29use crate::Codex;
30use crate::command::exec::{ExecCommand, ExecResumeCommand};
31use crate::error::{Error, Result};
32use crate::types::JsonLineEvent;
33
34#[derive(Debug, Clone)]
36pub struct TurnRecord {
37 pub events: Vec<JsonLineEvent>,
39}
40
41pub struct Session {
71 codex: Arc<Codex>,
72 thread_id: Option<String>,
73 history: Vec<TurnRecord>,
74}
75
76impl Session {
77 pub fn new(codex: Arc<Codex>) -> Self {
81 Self {
82 codex,
83 thread_id: None,
84 history: Vec::new(),
85 }
86 }
87
88 pub fn resume(codex: Arc<Codex>, thread_id: impl Into<String>) -> Self {
93 Self {
94 codex,
95 thread_id: Some(thread_id.into()),
96 history: Vec::new(),
97 }
98 }
99
100 pub async fn send(&mut self, prompt: impl Into<String>) -> Result<Vec<JsonLineEvent>> {
108 let prompt = prompt.into();
109
110 match &self.thread_id {
111 None => {
112 let cmd = ExecCommand::new(&prompt);
113 self.run_exec(cmd).await
114 }
115 Some(id) => {
116 let cmd = ExecResumeCommand::new()
117 .session_id(id.clone())
118 .prompt(prompt);
119 self.run_resume(cmd).await
120 }
121 }
122 }
123
124 pub async fn execute(&mut self, cmd: ExecCommand) -> Result<Vec<JsonLineEvent>> {
130 self.run_exec(cmd).await
131 }
132
133 pub async fn execute_resume(&mut self, cmd: ExecResumeCommand) -> Result<Vec<JsonLineEvent>> {
139 self.run_resume(cmd).await
140 }
141
142 #[must_use]
148 pub fn id(&self) -> Option<&str> {
149 self.thread_id.as_deref()
150 }
151
152 #[must_use]
154 pub fn total_turns(&self) -> usize {
155 self.history.len()
156 }
157
158 #[must_use]
160 pub fn history(&self) -> &[TurnRecord] {
161 &self.history
162 }
163
164 async fn run_exec(&mut self, cmd: ExecCommand) -> Result<Vec<JsonLineEvent>> {
166 match cmd.execute_json_lines(&self.codex).await {
167 Ok(events) => {
168 self.capture_thread_id(&events);
169 self.history.push(TurnRecord {
170 events: events.clone(),
171 });
172 Ok(events)
173 }
174 Err(Error::CommandFailed {
175 stdout,
176 stderr,
177 exit_code,
178 command,
179 working_dir,
180 }) => {
181 self.try_capture_thread_id_from_stdout(&stdout);
182 Err(Error::CommandFailed {
183 stdout,
184 stderr,
185 exit_code,
186 command,
187 working_dir,
188 })
189 }
190 Err(e) => Err(e),
191 }
192 }
193
194 async fn run_resume(&mut self, cmd: ExecResumeCommand) -> Result<Vec<JsonLineEvent>> {
196 match cmd.execute_json_lines(&self.codex).await {
197 Ok(events) => {
198 self.capture_thread_id(&events);
199 self.history.push(TurnRecord {
200 events: events.clone(),
201 });
202 Ok(events)
203 }
204 Err(Error::CommandFailed {
205 stdout,
206 stderr,
207 exit_code,
208 command,
209 working_dir,
210 }) => {
211 self.try_capture_thread_id_from_stdout(&stdout);
212 Err(Error::CommandFailed {
213 stdout,
214 stderr,
215 exit_code,
216 command,
217 working_dir,
218 })
219 }
220 Err(e) => Err(e),
221 }
222 }
223
224 fn capture_thread_id(&mut self, events: &[JsonLineEvent]) {
226 if let Some(id) = events.iter().find_map(|e| e.thread_id()) {
227 self.thread_id = Some(id.to_string());
228 }
229 }
230
231 fn try_capture_thread_id_from_stdout(&mut self, stdout: &str) {
233 for line in stdout.lines() {
234 if let Ok(event) = serde_json::from_str::<JsonLineEvent>(line)
235 && let Some(id) = event.thread_id()
236 {
237 self.thread_id = Some(id.to_string());
238 return;
239 }
240 }
241 }
242}
243
244impl std::fmt::Debug for Session {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.debug_struct("Session")
247 .field("thread_id", &self.thread_id)
248 .field("total_turns", &self.history.len())
249 .finish_non_exhaustive()
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn test_codex() -> Arc<Codex> {
258 Arc::new(Codex::builder().binary("/usr/bin/false").build().unwrap())
259 }
260
261 #[test]
262 fn new_session_has_no_state() {
263 let session = Session::new(test_codex());
264 assert!(session.id().is_none());
265 assert_eq!(session.total_turns(), 0);
266 assert!(session.history().is_empty());
267 }
268
269 #[test]
270 fn resume_session_has_thread_id() {
271 let session = Session::resume(test_codex(), "thread_abc");
272 assert_eq!(session.id(), Some("thread_abc"));
273 assert_eq!(session.total_turns(), 0);
274 }
275
276 #[test]
277 fn capture_thread_id_from_events() {
278 let mut session = Session::new(test_codex());
279 let events: Vec<JsonLineEvent> = vec![
280 serde_json::from_str(r#"{"type":"message.created","role":"assistant"}"#).unwrap(),
281 serde_json::from_str(
282 r#"{"type":"thread.started","thread_id":"thread_xyz","session_id":"sess_1"}"#,
283 )
284 .unwrap(),
285 ];
286 session.capture_thread_id(&events);
287 assert_eq!(session.id(), Some("thread_xyz"));
288 }
289
290 #[test]
291 fn capture_thread_id_noop_when_absent() {
292 let mut session = Session::new(test_codex());
293 let events: Vec<JsonLineEvent> =
294 vec![serde_json::from_str(r#"{"type":"message.created"}"#).unwrap()];
295 session.capture_thread_id(&events);
296 assert!(session.id().is_none());
297 }
298
299 #[test]
300 fn try_capture_thread_id_from_stdout_parses_json() {
301 let mut session = Session::new(test_codex());
302 let stdout = r#"{"type":"thread.started","thread_id":"thread_err"}
303{"type":"error","message":"something went wrong"}"#;
304 session.try_capture_thread_id_from_stdout(stdout);
305 assert_eq!(session.id(), Some("thread_err"));
306 }
307
308 #[test]
309 fn try_capture_thread_id_from_stdout_ignores_garbage() {
310 let mut session = Session::new(test_codex());
311 session.try_capture_thread_id_from_stdout("not json\nalso not json");
312 assert!(session.id().is_none());
313 }
314
315 #[test]
316 fn debug_impl() {
317 let session = Session::resume(test_codex(), "thread_dbg");
318 let debug = format!("{session:?}");
319 assert!(debug.contains("thread_dbg"));
320 assert!(debug.contains("total_turns: 0"));
321 }
322}