1use std::str;
8
9use crate::cli_common::{CliRunnerBase, MAX_OUTPUT_BYTES};
10use crate::types::{
11 ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError, StreamChunk,
12};
13use async_trait::async_trait;
14use tokio::process::Command;
15use tracing::instrument;
16
17use crate::config::RunnerConfig;
18use crate::process::run_cli_command;
19use crate::prompt::prepare_prompt;
20use crate::sandbox::{apply_sandbox, build_policy};
21
22const DEFAULT_MODEL: &str = "auto";
24
25const FALLBACK_MODELS: &[&str] = &["auto"];
27
28pub struct ContinueCliRunner {
35 base: CliRunnerBase,
36}
37
38impl ContinueCliRunner {
39 #[must_use]
41 pub fn new(config: RunnerConfig) -> Self {
42 Self {
43 base: CliRunnerBase::new(config, DEFAULT_MODEL, FALLBACK_MODELS),
44 }
45 }
46
47 fn build_command(&self, prompt: &str) -> Command {
49 let mut cmd = Command::new(&self.base.config.binary_path);
50 cmd.args(["-p", "--format", "json", prompt]);
51
52 if let Some(model) = self.base.config.model.as_deref() {
53 cmd.args(["--model", model]);
54 }
55
56 for arg in &self.base.config.extra_args {
57 cmd.arg(arg);
58 }
59
60 if let Ok(policy) = build_policy(&self.base.config) {
61 apply_sandbox(&mut cmd, &policy);
62 }
63
64 cmd
65 }
66
67 fn parse_json_response(raw: &[u8]) -> Result<ChatResponse, RunnerError> {
73 let text = str::from_utf8(raw).map_err(|e| {
74 RunnerError::internal(format!("Continue CLI output is not valid UTF-8: {e}"))
75 })?;
76
77 for line in text.lines() {
78 let trimmed = line.trim();
79 if trimmed.is_empty() {
80 continue;
81 }
82 let value: serde_json::Value = match serde_json::from_str(trimmed) {
83 Ok(v) => v,
84 Err(_) => continue,
85 };
86
87 if value.get("response").is_some() {
89 let content = value
90 .get("response")
91 .and_then(|v| v.as_str())
92 .unwrap_or("")
93 .to_owned();
94
95 return Ok(ChatResponse {
96 content,
97 model: "continue".to_owned(),
98 usage: None,
99 finish_reason: Some("stop".to_owned()),
100 warnings: None,
101 tool_calls: None,
102 });
103 }
104 }
105
106 Ok(ChatResponse {
108 content: String::new(),
109 model: "continue".to_owned(),
110 usage: None,
111 finish_reason: Some("stop".to_owned()),
112 warnings: None,
113 tool_calls: None,
114 })
115 }
116}
117
118#[async_trait]
119impl LlmProvider for ContinueCliRunner {
120 crate::delegate_provider_base!("continue", "Continue CLI", LlmCapabilities::empty());
121
122 #[instrument(skip_all, fields(runner = "continue"))]
123 async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
124 let prepared = prepare_prompt(&request.messages)?;
125 let prompt = &prepared.prompt;
126 let mut cmd = self.build_command(prompt);
127
128 let output = run_cli_command(&mut cmd, self.base.config.timeout, MAX_OUTPUT_BYTES).await?;
129 self.base.check_exit_code(&output, "continue")?;
130
131 Self::parse_json_response(&output.stdout)
132 }
133
134 #[instrument(skip_all, fields(runner = "continue"))]
135 async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
136 let response = self.complete(request).await?;
138 let chunk = StreamChunk {
139 delta: response.content,
140 is_final: true,
141 finish_reason: Some("stop".to_owned()),
142 };
143 Ok(Box::pin(tokio_stream::once(Ok(chunk))))
144 }
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use std::path::PathBuf;
151
152 #[test]
153 fn test_parse_json_response_basic() {
154 let json = br#"{"response":"hello from continue","status":"success","note":"completed"}"#;
155 let resp = ContinueCliRunner::parse_json_response(json).unwrap(); assert_eq!(resp.content, "hello from continue");
157 assert!(resp.usage.is_none());
158 }
159
160 #[test]
161 fn test_parse_json_response_skips_lifecycle_events() {
162 let ndjson = br#"{"status":"info","message":"Auto-compacting triggered"}
163{"response":"actual answer","status":"success"}"#;
164 let resp = ContinueCliRunner::parse_json_response(ndjson).unwrap(); assert_eq!(resp.content, "actual answer");
166 }
167
168 #[test]
169 fn test_parse_json_response_empty_output() {
170 let empty = b"";
171 let resp = ContinueCliRunner::parse_json_response(empty).unwrap(); assert_eq!(resp.content, "");
173 }
174
175 #[test]
176 fn test_default_model() {
177 let config = RunnerConfig::new(PathBuf::from("cn"));
178 let runner = ContinueCliRunner::new(config);
179 assert_eq!(runner.default_model(), "auto");
180 }
181
182 #[test]
183 fn test_capabilities_no_streaming() {
184 let config = RunnerConfig::new(PathBuf::from("cn"));
185 let runner = ContinueCliRunner::new(config);
186 assert!(!runner.capabilities().supports_streaming());
187 }
188
189 #[test]
190 fn test_name_and_display() {
191 let config = RunnerConfig::new(PathBuf::from("cn"));
192 let runner = ContinueCliRunner::new(config);
193 assert_eq!(runner.name(), "continue");
194 assert_eq!(runner.display_name(), "Continue CLI");
195 }
196}