1use anyhow::Result;
9use chrono::{DateTime, Duration, Utc};
10use rusqlite::{Connection, params};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SuggestionRow {
15 pub id: i64,
16 pub session_id: String,
17 pub tool_id: String,
18 pub task_text: Option<String>,
19 pub score: Option<f64>,
20 pub suggested_at: String,
21 pub accepted: bool,
22 pub accepted_at: Option<String>,
23}
24
25pub fn record(
27 conn: &Connection,
28 session_id: &str,
29 tool_id: &str,
30 task_text: Option<&str>,
31 score: Option<f64>,
32 suggested_at: DateTime<Utc>,
33) -> Result<i64> {
34 conn.execute(
35 "INSERT INTO agent_suggestions
36 (session_id, tool_id, task_text, score, suggested_at, accepted)
37 VALUES (?, ?, ?, ?, ?, 0)",
38 params![
39 session_id,
40 tool_id,
41 task_text,
42 score,
43 suggested_at.to_rfc3339(),
44 ],
45 )?;
46 Ok(conn.last_insert_rowid())
47}
48
49pub fn mark_accepted(
54 conn: &Connection,
55 session_id: &str,
56 tool_id: &str,
57 accepted_at: DateTime<Utc>,
58 window_minutes: i64,
59) -> Result<usize> {
60 let cutoff = (accepted_at - Duration::minutes(window_minutes)).to_rfc3339();
61 let n = conn.execute(
62 "UPDATE agent_suggestions
63 SET accepted = 1, accepted_at = ?
64 WHERE session_id = ?
65 AND tool_id = ?
66 AND accepted = 0
67 AND suggested_at >= ?",
68 params![accepted_at.to_rfc3339(), session_id, tool_id, cutoff],
69 )?;
70 Ok(n)
71}
72
73pub fn acceptance_stats(conn: &Connection, since: DateTime<Utc>) -> Result<(i64, i64)> {
75 let cutoff = since.to_rfc3339();
76 let row = conn.query_row(
77 "SELECT
78 COUNT(*) AS n,
79 COALESCE(SUM(accepted), 0) AS k
80 FROM agent_suggestions
81 WHERE suggested_at >= ?",
82 params![cutoff],
83 |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)),
84 )?;
85 Ok(row)
86}
87
88pub fn list(conn: &Connection, session_id: Option<&str>) -> Result<Vec<SuggestionRow>> {
89 let (sql, params): (&str, Vec<Box<dyn rusqlite::ToSql>>) = match session_id {
90 Some(sid) => (
91 "SELECT id, session_id, tool_id, task_text, score, suggested_at,
92 accepted, accepted_at
93 FROM agent_suggestions
94 WHERE session_id = ?
95 ORDER BY suggested_at DESC",
96 vec![Box::new(sid.to_string())],
97 ),
98 None => (
99 "SELECT id, session_id, tool_id, task_text, score, suggested_at,
100 accepted, accepted_at
101 FROM agent_suggestions
102 ORDER BY suggested_at DESC",
103 vec![],
104 ),
105 };
106 let mut stmt = conn.prepare(sql)?;
107 let param_refs: Vec<&dyn rusqlite::ToSql> = params
108 .iter()
109 .map(|b| b.as_ref() as &dyn rusqlite::ToSql)
110 .collect();
111 let rows = stmt
112 .query_map(param_refs.as_slice(), |row| {
113 Ok(SuggestionRow {
114 id: row.get(0)?,
115 session_id: row.get(1)?,
116 tool_id: row.get(2)?,
117 task_text: row.get(3)?,
118 score: row.get(4)?,
119 suggested_at: row.get(5)?,
120 accepted: row.get::<_, i64>(6)? != 0,
121 accepted_at: row.get(7)?,
122 })
123 })?
124 .collect::<Result<Vec<_>, _>>()?;
125 Ok(rows)
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131 use crate::open;
132
133 fn tmp_conn() -> (tempfile::TempDir, Connection) {
134 let dir = tempfile::tempdir().unwrap();
135 let conn = open(&dir.path().join("t.sqlite")).unwrap();
136 (dir, conn)
137 }
138
139 fn seed_tool(conn: &Connection, id: &str) {
140 conn.execute(
141 "INSERT OR IGNORE INTO tools (id, type, name, triggers, examples, requires,
142 enabled, added_at, last_seen_at)
143 VALUES (?, 'skill', ?, '[]', '[]', '[]', 1,
144 '2026-05-03T00:00:00+00:00', '2026-05-03T00:00:00+00:00')",
145 params![id, id],
146 )
147 .unwrap();
148 }
149
150 #[test]
151 fn record_then_mark_accepted_flips_row() {
152 let (_d, conn) = tmp_conn();
153 seed_tool(&conn, "skill:caveman");
154 let suggested = Utc::now();
155 record(
156 &conn,
157 "sess-1",
158 "skill:caveman",
159 Some("be terse"),
160 Some(0.9),
161 suggested,
162 )
163 .unwrap();
164
165 let n = mark_accepted(&conn, "sess-1", "skill:caveman", suggested, 60).unwrap();
166 assert_eq!(n, 1);
167
168 let rows = list(&conn, Some("sess-1")).unwrap();
169 assert_eq!(rows.len(), 1);
170 assert!(rows[0].accepted);
171 assert!(rows[0].accepted_at.is_some());
172 }
173
174 #[test]
175 fn mark_accepted_ignores_old_suggestions() {
176 let (_d, conn) = tmp_conn();
177 seed_tool(&conn, "skill:caveman");
178 let old = Utc::now() - Duration::hours(2);
179 record(&conn, "sess-1", "skill:caveman", None, None, old).unwrap();
180
181 let n = mark_accepted(&conn, "sess-1", "skill:caveman", Utc::now(), 60).unwrap();
183 assert_eq!(n, 0);
184
185 let rows = list(&conn, None).unwrap();
186 assert!(!rows[0].accepted);
187 }
188
189 #[test]
190 fn mark_accepted_is_idempotent() {
191 let (_d, conn) = tmp_conn();
192 seed_tool(&conn, "skill:x");
193 let ts = Utc::now();
194 record(&conn, "s", "skill:x", None, None, ts).unwrap();
195 assert_eq!(mark_accepted(&conn, "s", "skill:x", ts, 60).unwrap(), 1);
196 assert_eq!(mark_accepted(&conn, "s", "skill:x", ts, 60).unwrap(), 0);
198 }
199
200 #[test]
201 fn acceptance_stats_counts_correctly() {
202 let (_d, conn) = tmp_conn();
203 seed_tool(&conn, "skill:a");
204 seed_tool(&conn, "skill:b");
205 let now = Utc::now();
206 record(&conn, "s1", "skill:a", None, None, now).unwrap();
207 record(&conn, "s1", "skill:b", None, None, now).unwrap();
208 record(&conn, "s2", "skill:a", None, None, now).unwrap();
209 mark_accepted(&conn, "s1", "skill:a", now, 60).unwrap();
210
211 let (n, k) = acceptance_stats(&conn, now - Duration::hours(1)).unwrap();
212 assert_eq!(n, 3);
213 assert_eq!(k, 1);
214 }
215}