Skip to main content

embacle/
continue_cli.rs

1// ABOUTME: Continue CLI runner implementing the `LlmProvider` trait
2// ABOUTME: Wraps the `cn` CLI with JSON output parsing; no streaming support
3//
4// SPDX-License-Identifier: Apache-2.0
5// Copyright (c) 2026 dravr.ai
6
7use 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
22/// Default model for Continue CLI (provider-agnostic)
23const DEFAULT_MODEL: &str = "auto";
24
25/// Fallback model list (Continue delegates to whatever backend the user configured)
26const FALLBACK_MODELS: &[&str] = &["auto"];
27
28/// Continue CLI runner
29///
30/// Implements `LlmProvider` by delegating to the `cn` binary with
31/// `-p --format json` for non-interactive JSON output. Streaming is
32/// not supported; `complete_stream()` wraps `complete()` in a single-chunk
33/// stream via `tokio_stream::once`.
34pub struct ContinueCliRunner {
35    base: CliRunnerBase,
36}
37
38impl ContinueCliRunner {
39    /// Create a new Continue CLI runner with the given configuration
40    #[must_use]
41    pub fn new(config: RunnerConfig) -> Self {
42        Self {
43            base: CliRunnerBase::new(config, DEFAULT_MODEL, FALLBACK_MODELS),
44        }
45    }
46
47    /// Build the command with all arguments
48    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    /// Parse JSON output from `cn -p --format json`
68    ///
69    /// Continue may emit lifecycle NDJSON lines (status/info events) before the
70    /// actual response object. We scan lines for the first object containing a
71    /// `"response"` field.
72    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            // Skip lifecycle events (status/info lines)
88            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        // No response object found; return empty content
107        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        // Continue CLI does not support streaming; wrap `complete()` as a single chunk
137        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(); // Safe: test assertion
156        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(); // Safe: test assertion
165        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(); // Safe: test assertion
172        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}