1use std::sync::Arc;
2
3use algocline_core::QueryId;
4use algocline_engine::{FeedResult, VariantPkg};
5
6use super::eval_store::{splice_response_string, splice_response_warnings};
7use super::resolve::{is_package_installed, make_require_code, resolve_code, QueryResponse};
8use super::transcript::write_transcript_log;
9use super::AppService;
10
11fn splice_save_warning(result_json: &str, warning: Option<String>) -> String {
15 match warning {
16 Some(msg) => splice_response_string(result_json, "save_warning", &msg),
17 None => result_json.to_string(),
18 }
19}
20
21fn splice_transcript_warning(result_json: &str, warning: Option<String>) -> String {
25 match warning {
26 Some(msg) => splice_response_string(result_json, "transcript_warning", &msg),
27 None => result_json.to_string(),
28 }
29}
30
31impl AppService {
32 pub async fn run(
37 &self,
38 code: Option<String>,
39 code_file: Option<String>,
40 ctx: Option<serde_json::Value>,
41 project_root: Option<String>,
42 ) -> Result<String, String> {
43 let code = resolve_code(code, code_file)?;
44 let ctx = ctx.unwrap_or(serde_json::Value::Null);
45 let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
46 let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
47 let mut warnings: Vec<String> = extra_warnings;
48 warnings.extend(variant_warnings);
49 let json = self
50 .start_and_tick(code, ctx, None, extra, variants)
51 .await?;
52 Ok(splice_response_warnings(
53 &json,
54 "lib_path_warnings",
55 &warnings,
56 ))
57 }
58
59 pub async fn advice(
67 &self,
68 strategy: &str,
69 task: Option<String>,
70 opts: Option<serde_json::Value>,
71 project_root: Option<String>,
72 ) -> Result<String, String> {
73 let app_dir = self.log_config.app_dir();
75 if !is_package_installed(&app_dir, strategy) {
76 self.auto_install_bundled_packages().await?;
77 if !is_package_installed(&app_dir, strategy) {
78 return Err(format!(
79 "Package '{strategy}' not found after installing bundled collection. \
80 Use alc_pkg_install to install it manually."
81 ));
82 }
83 }
84
85 let code = make_require_code(strategy);
86
87 let mut ctx_map = match opts {
88 Some(serde_json::Value::Object(m)) => m,
89 _ => serde_json::Map::new(),
90 };
91 if let Some(task) = task {
92 ctx_map.insert("task".into(), serde_json::Value::String(task));
93 }
94 let ctx = serde_json::Value::Object(ctx_map);
95
96 let (extra, extra_warnings) = self.resolve_extra_lib_paths(project_root.as_deref());
97 let (variants, variant_warnings) = self.resolve_variant_pkgs(project_root.as_deref());
98 let mut warnings: Vec<String> = extra_warnings;
99 warnings.extend(variant_warnings);
100 let json = self
101 .start_and_tick(code, ctx, Some(strategy), extra, variants)
102 .await?;
103 Ok(splice_response_warnings(
104 &json,
105 "lib_path_warnings",
106 &warnings,
107 ))
108 }
109
110 pub async fn continue_batch(
112 &self,
113 session_id: &str,
114 responses: Vec<QueryResponse>,
115 ) -> Result<String, String> {
116 let mut last_result = None;
117 for qr in responses {
118 let qid = QueryId::parse(&qr.query_id);
119 let result = self
120 .registry
121 .feed_response(session_id, &qid, qr.response, qr.usage.as_ref())
122 .await
123 .map_err(|e| format!("Continue failed: {e}"))?;
124 last_result = Some(result);
125 }
126 let result = last_result.ok_or("Empty responses array")?;
127 let transcript_warning = self.maybe_log_transcript(&result, session_id);
128 let json = result.to_json(session_id).to_string();
129 let json = splice_transcript_warning(&json, transcript_warning);
130 let save_warning = self.maybe_save_eval(&result, session_id, &json);
131 Ok(splice_save_warning(&json, save_warning))
132 }
133
134 pub async fn continue_single(
136 &self,
137 session_id: &str,
138 response: String,
139 query_id: Option<&str>,
140 usage: Option<algocline_core::TokenUsage>,
141 ) -> Result<String, String> {
142 let query_id = match query_id {
143 Some(qid) => QueryId::parse(qid),
144 None => self
145 .registry
146 .resolve_sole_pending_id(session_id)
147 .await
148 .map_err(|e| format!("Continue failed: {e}"))?,
149 };
150
151 let result = self
152 .registry
153 .feed_response(session_id, &query_id, response, usage.as_ref())
154 .await
155 .map_err(|e| format!("Continue failed: {e}"))?;
156
157 let transcript_warning = self.maybe_log_transcript(&result, session_id);
158 let json = result.to_json(session_id).to_string();
159 let json = splice_transcript_warning(&json, transcript_warning);
160 let save_warning = self.maybe_save_eval(&result, session_id, &json);
161 Ok(splice_save_warning(&json, save_warning))
162 }
163
164 pub(super) fn maybe_log_transcript(
167 &self,
168 result: &FeedResult,
169 session_id: &str,
170 ) -> Option<String> {
171 if let FeedResult::Finished(exec_result) = result {
172 let strategy = match self.session_strategies.lock() {
177 Ok(mut map) => map.remove(session_id),
178 Err(e) => {
179 tracing::warn!(
180 "session_strategies mutex poisoned for '{}': {}",
181 session_id,
182 e
183 );
184 return Some(format!(
187 "session_strategies mutex poisoned for '{session_id}': {e}"
188 ));
189 }
190 };
191 match write_transcript_log(
195 &self.log_config,
196 session_id,
197 &exec_result.metrics,
198 strategy.as_deref(),
199 ) {
200 Err(e) => Some(e.to_string()),
201 Ok(meta_warning) => meta_warning,
202 }
203 } else {
204 None
205 }
206 }
207
208 pub(super) fn maybe_save_eval(
214 &self,
215 result: &FeedResult,
216 session_id: &str,
217 result_json: &str,
218 ) -> Option<String> {
219 if !matches!(result, FeedResult::Finished(_)) {
220 return None;
221 }
222 let strategy = {
223 let mut map = self.eval_sessions.lock().unwrap_or_else(|e| e.into_inner());
224 map.remove(session_id)
225 };
226 strategy.and_then(|s| {
227 super::eval_store::save_eval_result(&self.log_config.app_dir(), &s, result_json).err()
228 })
229 }
230
231 pub(super) async fn start_and_tick(
232 &self,
233 code: String,
234 ctx: serde_json::Value,
235 strategy: Option<&str>,
236 extra_lib_paths: Vec<std::path::PathBuf>,
237 variant_pkgs: Vec<VariantPkg>,
238 ) -> Result<String, String> {
239 let scenarios_dir = self.log_config.app_dir().scenarios_dir();
240 let session = self
241 .executor
242 .start_session(
243 code,
244 ctx,
245 extra_lib_paths,
246 variant_pkgs,
247 Arc::clone(&self.state_store),
248 Arc::clone(&self.card_store),
249 scenarios_dir,
250 )
251 .await?;
252 let (session_id, result) = self
253 .registry
254 .start_execution(session)
255 .await
256 .map_err(|e| format!("Execution failed: {e}"))?;
257 if let Some(s) = strategy {
258 if let Ok(mut map) = self.session_strategies.lock() {
259 map.insert(session_id.clone(), s.to_string());
260 }
261 }
262 let transcript_warning = self.maybe_log_transcript(&result, &session_id);
263 let json = result.to_json(&session_id).to_string();
264 Ok(splice_transcript_warning(&json, transcript_warning))
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use std::path::PathBuf;
271 use std::sync::Arc;
272
273 use algocline_core::{
274 AppDir, ExecutionMetrics, ExecutionObserver, LlmQuery, QueryId, TerminalState,
275 };
276 use algocline_engine::{ExecutionResult, FeedResult};
277
278 use super::super::config::{AppConfig, LogDirSource};
279 use super::{splice_transcript_warning, AppService};
280
281 fn make_metrics_with_transcript() -> ExecutionMetrics {
282 let metrics = ExecutionMetrics::new();
283 let observer = metrics.create_observer();
284 observer.on_paused(&[LlmQuery {
285 id: QueryId::single(),
286 prompt: "test prompt".into(),
287 system: None,
288 max_tokens: 100,
289 grounded: false,
290 underspecified: false,
291 }]);
292 metrics
293 }
294
295 fn make_finished_result(metrics: ExecutionMetrics) -> FeedResult {
296 FeedResult::Finished(ExecutionResult {
297 state: TerminalState::Completed {
298 result: serde_json::json!({"ok": true}),
299 },
300 metrics,
301 })
302 }
303
304 async fn make_app_service_with_log_dir(log_dir: PathBuf) -> AppService {
306 let executor = Arc::new(
307 algocline_engine::Executor::new(vec![])
308 .await
309 .expect("executor"),
310 );
311 let tmp_app = tempfile::tempdir().expect("test tempdir");
312 let log_config = AppConfig {
313 log_dir: Some(log_dir),
314 log_dir_source: LogDirSource::EnvVar,
315 log_enabled: true,
316 prompt_preview_chars: 200,
317 app_dir: Arc::new(AppDir::new(tmp_app.path().to_path_buf())),
318 };
319 std::mem::forget(tmp_app);
320 AppService::new(executor, log_config, vec![])
321 }
322
323 #[tokio::test]
326 async fn maybe_log_transcript_returns_some_on_write_failure() {
327 let tmp = tempfile::tempdir().expect("test tempdir");
328 let log_dir = tmp.path().to_path_buf();
329 std::fs::create_dir_all(log_dir.join("fail-session.json"))
331 .expect("pre-create dir to block write");
332 let svc = make_app_service_with_log_dir(log_dir).await;
333 let metrics = make_metrics_with_transcript();
334 let result = make_finished_result(metrics);
335 let warning = svc.maybe_log_transcript(&result, "fail-session");
336 assert!(warning.is_some(), "expected Some warning on write failure");
337 let msg = warning.unwrap();
338 assert!(
339 msg.contains("transcript"),
340 "warning should mention 'transcript', got: {msg}"
341 );
342 }
343
344 #[tokio::test]
345 async fn maybe_log_transcript_returns_none_on_non_finished() {
346 let tmp = tempfile::tempdir().expect("test tempdir");
347 let svc = make_app_service_with_log_dir(tmp.path().to_path_buf()).await;
348 let result = FeedResult::Accepted { remaining: 1 };
349 let warning = svc.maybe_log_transcript(&result, "any-session");
350 assert!(warning.is_none(), "Accepted result should return None");
351 }
352
353 #[test]
356 fn splice_transcript_warning_injects_field_when_some() {
357 let json = r#"{"status":"finished","result":{}}"#;
358 let out = splice_transcript_warning(json, Some("write failed".to_string()));
359 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
360 assert_eq!(
361 v["transcript_warning"].as_str(),
362 Some("write failed"),
363 "transcript_warning field should be present"
364 );
365 assert_eq!(v["status"].as_str(), Some("finished"));
367 }
368
369 #[test]
370 fn splice_transcript_warning_passthrough_when_none() {
371 let json = r#"{"status":"finished"}"#;
372 let out = splice_transcript_warning(json, None);
373 let v: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
374 assert!(
375 v.get("transcript_warning").is_none(),
376 "transcript_warning must be absent when warning is None"
377 );
378 }
379}