Skip to main content

codex_relay/
session.rs

1use dashmap::DashMap;
2use std::hash::{DefaultHasher, Hash, Hasher};
3use std::sync::Arc;
4use uuid::Uuid;
5
6use crate::types::ChatMessage;
7
8/// Maps response_id → accumulated message history for that session.
9/// Codex uses `previous_response_id` to continue a conversation; we maintain
10/// the full messages[] here so each Chat Completions call is self-contained.
11///
12/// Also maintains call_id → reasoning_content so that thinking-capable models
13/// (e.g. kimi-k2.6) can have their reasoning_content round-tripped back when
14/// Codex replays tool-call history in subsequent requests.
15///
16/// For assistant messages without tool calls (pure text), reasoning_content
17/// is indexed by a fingerprint of the prior messages + assistant content,
18/// so it can be recovered when Codex replays the full conversation in `input`
19/// without using `previous_response_id`.
20#[derive(Clone)]
21pub struct SessionStore {
22    inner: Arc<DashMap<String, Vec<ChatMessage>>>,
23    reasoning: Arc<DashMap<String, String>>,
24    /// fingerprint(prior_messages, assistant_content) → reasoning_content
25    turn_reasoning: Arc<DashMap<u64, String>>,
26}
27
28impl SessionStore {
29    pub fn new() -> Self {
30        Self {
31            inner: Arc::new(DashMap::new()),
32            reasoning: Arc::new(DashMap::new()),
33            turn_reasoning: Arc::new(DashMap::new()),
34        }
35    }
36
37    /// Store reasoning_content keyed by the tool call_id so it can be
38    /// injected back when the same call_id appears in a subsequent request.
39    pub fn store_reasoning(&self, call_id: String, reasoning: String) {
40        if !reasoning.is_empty() {
41            self.reasoning.insert(call_id, reasoning);
42        }
43    }
44
45    /// Look up stored reasoning_content for a call_id.
46    pub fn get_reasoning(&self, call_id: &str) -> Option<String> {
47        self.reasoning.get(call_id).map(|v| v.clone())
48    }
49
50    /// Store reasoning_content for an assistant turn, keyed by a fingerprint
51    /// of the assistant message content and tool calls.
52    pub fn store_turn_reasoning(&self, _prior: &[ChatMessage], assistant: &ChatMessage, reasoning: String) {
53        if !reasoning.is_empty() {
54            // Store under content-only key so lookups work even when Codex
55            // replays the assistant text and function_calls as separate items.
56            let content = assistant.content.as_deref().unwrap_or("");
57            if !content.is_empty() {
58                let key = Self::content_key(content);
59                self.turn_reasoning.insert(key, reasoning.clone());
60            }
61            // Also store under each tool call_id (existing mechanism).
62            if let Some(tcs) = &assistant.tool_calls {
63                for tc in tcs {
64                    if let Some(id) = tc.get("id").and_then(|v| v.as_str()) {
65                        if !id.is_empty() {
66                            self.store_reasoning(id.to_string(), reasoning.clone());
67                        }
68                    }
69                }
70            }
71        }
72    }
73
74    /// Look up reasoning_content for an assistant turn by its text content.
75    pub fn get_turn_reasoning(&self, _prior: &[ChatMessage], assistant: &ChatMessage) -> Option<String> {
76        let content = assistant.content.as_deref().unwrap_or("");
77        if content.is_empty() {
78            return None;
79        }
80        let key = Self::content_key(content);
81        self.turn_reasoning.get(&key).map(|v| v.clone())
82    }
83
84    /// Hash assistant message content for turn-level reasoning lookup.
85    fn content_key(content: &str) -> u64 {
86        let mut hasher = DefaultHasher::new();
87        content.hash(&mut hasher);
88        hasher.finish()
89    }
90
91    /// Retrieve history for a prior response_id, or empty vec if not found.
92    pub fn get_history(&self, response_id: &str) -> Vec<ChatMessage> {
93        self.inner
94            .get(response_id)
95            .map(|v| v.clone())
96            .unwrap_or_default()
97    }
98
99    /// Allocate a fresh response_id without storing anything yet.
100    /// Use with save_with_id() for the streaming path.
101    pub fn new_id(&self) -> String {
102        format!("resp_{}", Uuid::new_v4().simple())
103    }
104
105    /// Store under a pre-allocated response_id (streaming path).
106    pub fn save_with_id(&self, id: String, messages: Vec<ChatMessage>) {
107        self.inner.insert(id, messages);
108    }
109
110    /// Allocate an id and store atomically (non-streaming path).
111    pub fn save(&self, messages: Vec<ChatMessage>) -> String {
112        let id = self.new_id();
113        self.inner.insert(id.clone(), messages);
114        id
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::types::ChatMessage;
122
123    fn msg(role: &str, content: Option<&str>) -> ChatMessage {
124        ChatMessage {
125            role: role.into(),
126            content: content.map(Into::into),
127            reasoning_content: None,
128            tool_calls: None,
129            tool_call_id: None,
130            name: None,
131        }
132    }
133
134    #[test]
135    fn test_store_and_get_reasoning() {
136        let store = SessionStore::new();
137        store.store_reasoning("call_1".into(), "think".into());
138        assert_eq!(store.get_reasoning("call_1"), Some("think".into()));
139    }
140
141    #[test]
142    fn test_get_reasoning_missing() {
143        let store = SessionStore::new();
144        assert_eq!(store.get_reasoning("nonexistent"), None);
145    }
146
147    #[test]
148    fn test_empty_reasoning_not_stored() {
149        let store = SessionStore::new();
150        store.store_reasoning("call_e".into(), "".into());
151        assert_eq!(store.get_reasoning("call_e"), None);
152    }
153
154    #[test]
155    fn test_turn_reasoning_by_content() {
156        let store = SessionStore::new();
157        let assistant = msg("assistant", Some("hello world"));
158        store.store_turn_reasoning(&[], &assistant, "deep thought".into());
159        assert_eq!(
160            store.get_turn_reasoning(&[], &assistant),
161            Some("deep thought".into())
162        );
163    }
164
165    #[test]
166    fn test_turn_reasoning_empty_content() {
167        let store = SessionStore::new();
168        let assistant = msg("assistant", Some(""));
169        store.store_turn_reasoning(&[], &assistant, "reason".into());
170        assert_eq!(store.get_turn_reasoning(&[], &assistant), None);
171    }
172
173    #[test]
174    fn test_turn_reasoning_also_stores_call_ids() {
175        let store = SessionStore::new();
176        let mut assistant = msg("assistant", Some("hi"));
177        assistant.tool_calls = Some(vec![serde_json::json!({
178            "id": "call_123",
179            "type": "function",
180            "function": {"name": "exec", "arguments": "{}"}
181        })]);
182        store.store_turn_reasoning(&[], &assistant, "reason_tc".into());
183        assert_eq!(store.get_reasoning("call_123"), Some("reason_tc".into()));
184    }
185
186    #[test]
187    fn test_history_save_and_get() {
188        let store = SessionStore::new();
189        let msgs = vec![msg("user", Some("hi")), msg("assistant", Some("hey"))];
190        let id = store.save(msgs.clone());
191        let got = store.get_history(&id);
192        assert_eq!(got.len(), 2);
193        assert_eq!(got[0].content.as_deref(), Some("hi"));
194
195        // save_with_id
196        let id2 = store.new_id();
197        store.save_with_id(id2.clone(), vec![msg("user", Some("q"))]);
198        assert_eq!(store.get_history(&id2).len(), 1);
199    }
200
201    #[test]
202    fn test_content_key_deterministic() {
203        let a = SessionStore::content_key("same text");
204        let b = SessionStore::content_key("same text");
205        assert_eq!(a, b);
206        let c = SessionStore::content_key("different");
207        assert_ne!(a, c);
208    }
209}