sst Opencode (#239)

This commit is contained in:
Solomon
2025-07-17 18:36:14 +01:00
committed by GitHub
parent 3ed807f327
commit 9507836e6b
12 changed files with 1271 additions and 12 deletions

View File

@@ -26,6 +26,7 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.0", features = ["v4", "serde"] }
ts-rs = { version = "9.0", features = ["uuid-impl", "chrono-impl", "no-serde-warnings"] }
dirs = "5.0"
xdg = "3.0"
git2 = "0.18"
async-trait = "0.1"
libc = "0.2"

View File

@@ -12,7 +12,8 @@ export const EXECUTOR_TYPES: string[] = [
"amp",
"gemini",
"charm-opencode",
"claude-code-router"
"claude-code-router",
"sst-opencode"
];
export const EDITOR_TYPES: EditorType[] = [
@@ -31,7 +32,8 @@ export const EXECUTOR_LABELS: Record<string, string> = {
"amp": "Amp",
"gemini": "Gemini",
"charm-opencode": "Charm Opencode",
"claude-code-router": "Claude Code Router"
"claude-code-router": "Claude Code Router",
"sst-opencode": "SST Opencode"
};
export const EDITOR_LABELS: Record<string, string> = {

View File

@@ -8,7 +8,7 @@ use uuid::Uuid;
use crate::executors::{
AmpExecutor, CCRExecutor, CharmOpencodeExecutor, ClaudeExecutor, EchoExecutor, GeminiExecutor,
SetupScriptExecutor,
SetupScriptExecutor, SstOpencodeExecutor,
};
// Constants for database streaming - fast for near-real-time updates
@@ -358,6 +358,7 @@ pub enum ExecutorConfig {
ClaudeCodeRouter,
#[serde(alias = "charmopencode")]
CharmOpencode,
SstOpencode,
// Future executors can be added here
// Shell { command: String },
// Docker { image: String, command: String },
@@ -383,6 +384,7 @@ impl FromStr for ExecutorConfig {
"gemini" => Ok(ExecutorConfig::Gemini),
"charm-opencode" => Ok(ExecutorConfig::CharmOpencode),
"claude-code-router" => Ok(ExecutorConfig::ClaudeCodeRouter),
"sst-opencode" => Ok(ExecutorConfig::SstOpencode),
"setup-script" => Ok(ExecutorConfig::SetupScript {
script: "setup script".to_string(),
}),
@@ -401,6 +403,7 @@ impl ExecutorConfig {
ExecutorConfig::Gemini => Box::new(GeminiExecutor),
ExecutorConfig::ClaudeCodeRouter => Box::new(CCRExecutor::new()),
ExecutorConfig::CharmOpencode => Box::new(CharmOpencodeExecutor),
ExecutorConfig::SstOpencode => Box::new(SstOpencodeExecutor::new()),
ExecutorConfig::SetupScript { script } => {
Box::new(SetupScriptExecutor::new(script.clone()))
}
@@ -424,6 +427,9 @@ impl ExecutorConfig {
ExecutorConfig::Gemini => {
dirs::home_dir().map(|home| home.join(".gemini").join("settings.json"))
}
ExecutorConfig::SstOpencode => {
xdg::BaseDirectories::with_prefix("opencode").get_config_file("opencode.json")
}
ExecutorConfig::SetupScript { .. } => None,
}
}
@@ -433,6 +439,7 @@ impl ExecutorConfig {
match self {
ExecutorConfig::Echo => None, // Echo doesn't support MCP
ExecutorConfig::CharmOpencode => Some(vec!["mcpServers"]),
ExecutorConfig::SstOpencode => Some(vec!["mcp"]),
ExecutorConfig::Claude => Some(vec!["mcpServers"]),
ExecutorConfig::ClaudePlan => Some(vec!["mcpServers"]),
ExecutorConfig::Amp => Some(vec!["amp", "mcpServers"]), // Nested path for Amp
@@ -455,6 +462,7 @@ impl ExecutorConfig {
match self {
ExecutorConfig::Echo => "Echo (Test Mode)",
ExecutorConfig::CharmOpencode => "Charm Opencode",
ExecutorConfig::SstOpencode => "SST Opencode",
ExecutorConfig::Claude => "Claude",
ExecutorConfig::ClaudePlan => "Claude Plan",
ExecutorConfig::Amp => "Amp",
@@ -473,6 +481,7 @@ impl std::fmt::Display for ExecutorConfig {
ExecutorConfig::ClaudePlan => "claude-plan",
ExecutorConfig::Amp => "amp",
ExecutorConfig::Gemini => "gemini",
ExecutorConfig::SstOpencode => "sst-opencode",
ExecutorConfig::CharmOpencode => "charm-opencode",
ExecutorConfig::ClaudeCodeRouter => "claude-code-router",
ExecutorConfig::SetupScript { .. } => "setup-script",

View File

@@ -6,6 +6,7 @@ pub mod dev_server;
pub mod echo;
pub mod gemini;
pub mod setup_script;
pub mod sst_opencode;
pub use amp::{AmpExecutor, AmpFollowupExecutor};
pub use ccr::{CCRExecutor, CCRFollowupExecutor};
@@ -15,3 +16,4 @@ pub use dev_server::DevServerExecutor;
pub use echo::EchoExecutor;
pub use gemini::{GeminiExecutor, GeminiFollowupExecutor};
pub use setup_script::SetupScriptExecutor;
pub use sst_opencode::{SstOpencodeExecutor, SstOpencodeFollowupExecutor};

View File

@@ -0,0 +1,780 @@
use async_trait::async_trait;
use command_group::{AsyncCommandGroup, AsyncGroupChild};
use serde_json::{json, Value};
use tokio::{
io::{AsyncBufReadExt, BufReader},
process::Command,
};
use uuid::Uuid;
use crate::{
executor::{Executor, ExecutorError, NormalizedConversation, NormalizedEntry},
models::{execution_process::ExecutionProcess, executor_session::ExecutorSession, task::Task},
utils::shell::get_shell_command,
};
// Sub-modules for utilities
pub mod filter;
pub mod tools;
use self::{
filter::{parse_session_id_from_line, tool_usage_regex, OpenCodeFilter},
tools::{determine_action_type, generate_tool_content, normalize_tool_name},
};
struct Content {
pub stdout: Option<String>,
pub stderr: Option<String>,
}
/// Process a single line for session extraction and content formatting
async fn process_line_for_content(
line: &str,
session_extracted: &mut bool,
worktree_path: &str,
pool: &sqlx::SqlitePool,
execution_process_id: uuid::Uuid,
) -> Option<Content> {
if !*session_extracted {
if let Some(session_id) = parse_session_id_from_line(line) {
if let Err(e) =
ExecutorSession::update_session_id(pool, execution_process_id, &session_id).await
{
tracing::error!(
"Failed to update session ID for execution process {}: {}",
execution_process_id,
e
);
} else {
tracing::info!(
"Updated session ID {} for execution process {}",
session_id,
execution_process_id
);
*session_extracted = true;
}
// Don't return any content for session lines
return None;
}
}
// Check if line is noise - if so, discard it
if OpenCodeFilter::is_noise(line) {
return None;
}
if OpenCodeFilter::is_stderr(line) {
// If it's stderr, we don't need to process it further
return Some(Content {
stdout: None,
stderr: Some(line.to_string()),
});
}
// Format clean content as normalized JSON
let formatted = format_opencode_content_as_normalized_json(line, worktree_path);
Some(Content {
stdout: Some(formatted),
stderr: None,
})
}
/// Stream stderr from OpenCode process with filtering to separate clean output from noise
pub async fn stream_opencode_stderr_to_db(
output: impl tokio::io::AsyncRead + Unpin,
pool: sqlx::SqlitePool,
attempt_id: Uuid,
execution_process_id: Uuid,
worktree_path: String,
) {
let mut reader = BufReader::new(output);
let mut line = String::new();
let mut session_extracted = false;
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break, // EOF
Ok(_) => {
line = line.trim_end_matches(['\r', '\n']).to_string();
let content = process_line_for_content(
&line,
&mut session_extracted,
&worktree_path,
&pool,
execution_process_id,
)
.await;
if let Some(Content { stdout, stderr }) = content {
tracing::debug!(
"Processed OpenCode content for attempt {}: stdout={:?} stderr={:?}",
attempt_id,
stdout,
stderr,
);
if let Err(e) = ExecutionProcess::append_output(
&pool,
execution_process_id,
stdout.as_deref(),
stderr.as_deref(),
)
.await
{
tracing::error!(
"Failed to write OpenCode line for attempt {}: {}",
attempt_id,
e
);
}
}
}
Err(e) => {
tracing::error!("Error reading stderr for attempt {}: {}", attempt_id, e);
break;
}
}
}
}
/// Format OpenCode clean content as normalized JSON entries for direct database storage
fn format_opencode_content_as_normalized_json(content: &str, worktree_path: &str) -> String {
let mut results = Vec::new();
let base_timestamp = chrono::Utc::now();
let mut entry_counter = 0u32;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Generate unique timestamp for each entry by adding microseconds
let unique_timestamp =
base_timestamp + chrono::Duration::microseconds(entry_counter as i64);
let timestamp_str = unique_timestamp.to_rfc3339_opts(chrono::SecondsFormat::Micros, true);
entry_counter += 1;
// Try to parse as existing JSON first
if let Ok(parsed_json) = serde_json::from_str::<Value>(trimmed) {
results.push(parsed_json.to_string());
continue;
}
// Strip ANSI codes before processing
let cleaned = OpenCodeFilter::strip_ansi_codes(trimmed);
let cleaned_trim = cleaned.trim();
if cleaned_trim.is_empty() {
continue;
}
// Check for tool usage patterns after ANSI stripping: | ToolName {...}
if let Some(captures) = tool_usage_regex().captures(cleaned_trim) {
if let (Some(tool_name), Some(tool_input)) = (captures.get(1), captures.get(2)) {
// Parse tool input
let input: serde_json::Value =
serde_json::from_str(tool_input.as_str()).unwrap_or(serde_json::Value::Null);
// Normalize tool name for frontend compatibility (e.g., "Todo" → "todowrite")
let normalized_tool_name = normalize_tool_name(tool_name.as_str());
let normalized_entry = json!({
"timestamp": timestamp_str,
"entry_type": {
"type": "tool_use",
"tool_name": normalized_tool_name,
"action_type": determine_action_type(&normalized_tool_name, &input, worktree_path)
},
"content": generate_tool_content(&normalized_tool_name, &input, worktree_path),
"metadata": input
});
results.push(normalized_entry.to_string());
continue;
}
}
// Regular assistant message
let normalized_entry = json!({
"timestamp": timestamp_str,
"entry_type": {
"type": "assistant_message"
},
"content": cleaned_trim,
"metadata": null
});
results.push(normalized_entry.to_string());
}
// Ensure each JSON entry is on its own line
results.join("\n") + "\n"
}
/// An executor that uses SST Opencode CLI to process tasks
pub struct SstOpencodeExecutor {
executor_type: String,
command: String,
}
impl Default for SstOpencodeExecutor {
fn default() -> Self {
Self::new()
}
}
impl SstOpencodeExecutor {
/// Create a new SstOpencodeExecutor with default settings
pub fn new() -> Self {
Self {
executor_type: "SST Opencode".to_string(),
command: "npx -y opencode-ai@latest run --print-logs".to_string(),
}
}
}
/// An executor that resumes an SST Opencode session
pub struct SstOpencodeFollowupExecutor {
pub session_id: String,
pub prompt: String,
executor_type: String,
command_base: String,
}
impl SstOpencodeFollowupExecutor {
/// Create a new SstOpencodeFollowupExecutor with default settings
pub fn new(session_id: String, prompt: String) -> Self {
Self {
session_id,
prompt,
executor_type: "SST Opencode".to_string(),
command_base: "npx -y opencode-ai@latest run --print-logs".to_string(),
}
}
}
#[async_trait]
impl Executor for SstOpencodeExecutor {
async fn spawn(
&self,
pool: &sqlx::SqlitePool,
task_id: Uuid,
worktree_path: &str,
) -> Result<AsyncGroupChild, ExecutorError> {
// Get the task to fetch its description
let task = Task::find_by_id(pool, task_id)
.await?
.ok_or(ExecutorError::TaskNotFound)?;
let prompt = if let Some(task_description) = task.description {
format!(
r#"project_id: {}
Task title: {}
Task description: {}"#,
task.project_id, task.title, task_description
)
} else {
format!(
r#"project_id: {}
Task title: {}"#,
task.project_id, task.title
)
};
// Use shell command for cross-platform compatibility
let (shell_cmd, shell_arg) = get_shell_command();
let opencode_command = &self.command;
let mut command = Command::new(shell_cmd);
command
.kill_on_drop(true)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null()) // Ignore stdout for OpenCode
.stderr(std::process::Stdio::piped())
.current_dir(worktree_path)
.arg(shell_arg)
.arg(opencode_command)
.env("NODE_NO_WARNINGS", "1");
let mut child = command
.group_spawn() // Create new process group so we can kill entire tree
.map_err(|e| {
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_task(task_id, Some(task.title.clone()))
.with_context(format!("{} CLI execution for new task", self.executor_type))
.spawn_error(e)
})?;
// Write prompt to stdin safely
if let Some(mut stdin) = child.inner().stdin.take() {
use tokio::io::AsyncWriteExt;
tracing::debug!(
"Writing prompt to OpenCode stdin for task {}: {:?}",
task_id,
prompt
);
stdin.write_all(prompt.as_bytes()).await.map_err(|e| {
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_task(task_id, Some(task.title.clone()))
.with_context(format!(
"Failed to write prompt to {} CLI stdin",
self.executor_type
));
ExecutorError::spawn_failed(e, context)
})?;
stdin.shutdown().await.map_err(|e| {
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_task(task_id, Some(task.title.clone()))
.with_context(format!("Failed to close {} CLI stdin", self.executor_type));
ExecutorError::spawn_failed(e, context)
})?;
}
Ok(child)
}
/// Execute with OpenCode filtering for stderr
async fn execute_streaming(
&self,
pool: &sqlx::SqlitePool,
task_id: Uuid,
attempt_id: Uuid,
execution_process_id: Uuid,
worktree_path: &str,
) -> Result<command_group::AsyncGroupChild, ExecutorError> {
let mut child = self.spawn(pool, task_id, worktree_path).await?;
// Take stderr pipe for OpenCode filtering
let stderr = child
.inner()
.stderr
.take()
.expect("Failed to take stderr from child process");
// Start OpenCode stderr filtering task
let pool_clone = pool.clone();
let worktree_path_clone = worktree_path.to_string();
tokio::spawn(stream_opencode_stderr_to_db(
stderr,
pool_clone,
attempt_id,
execution_process_id,
worktree_path_clone,
));
Ok(child)
}
fn normalize_logs(
&self,
logs: &str,
_worktree_path: &str,
) -> Result<NormalizedConversation, String> {
let mut entries = Vec::new();
for line in logs.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// Simple passthrough: directly deserialize normalized JSON entries
if let Ok(entry) = serde_json::from_str::<NormalizedEntry>(trimmed) {
entries.push(entry);
}
}
Ok(NormalizedConversation {
entries,
session_id: None, // Session ID is stored directly in the database
executor_type: "sst-opencode".to_string(),
prompt: None,
summary: None,
})
}
}
#[async_trait]
impl Executor for SstOpencodeFollowupExecutor {
async fn spawn(
&self,
_pool: &sqlx::SqlitePool,
_task_id: Uuid,
worktree_path: &str,
) -> Result<AsyncGroupChild, ExecutorError> {
// Use shell command for cross-platform compatibility
let (shell_cmd, shell_arg) = get_shell_command();
let opencode_command = format!("{} --session {}", self.command_base, self.session_id);
let mut command = Command::new(shell_cmd);
command
.kill_on_drop(true)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null()) // Ignore stdout for OpenCode
.stderr(std::process::Stdio::piped())
.current_dir(worktree_path)
.arg(shell_arg)
.arg(&opencode_command)
.env("NODE_NO_WARNINGS", "1");
let mut child = command
.group_spawn() // Create new process group so we can kill entire tree
.map_err(|e| {
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_context(format!(
"{} CLI followup execution for session {}",
self.executor_type, self.session_id
))
.spawn_error(e)
})?;
// Write prompt to stdin safely
if let Some(mut stdin) = child.inner().stdin.take() {
use tokio::io::AsyncWriteExt;
tracing::debug!(
"Writing prompt to {} stdin for session {}: {:?}",
self.executor_type,
self.session_id,
self.prompt
);
stdin.write_all(self.prompt.as_bytes()).await.map_err(|e| {
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_context(format!(
"Failed to write prompt to {} CLI stdin for session {}",
self.executor_type, self.session_id
));
ExecutorError::spawn_failed(e, context)
})?;
stdin.shutdown().await.map_err(|e| {
let context =
crate::executor::SpawnContext::from_command(&command, &self.executor_type)
.with_context(format!(
"Failed to close {} CLI stdin for session {}",
self.executor_type, self.session_id
));
ExecutorError::spawn_failed(e, context)
})?;
}
Ok(child)
}
/// Execute with OpenCode filtering for stderr
async fn execute_streaming(
&self,
pool: &sqlx::SqlitePool,
task_id: Uuid,
attempt_id: Uuid,
execution_process_id: Uuid,
worktree_path: &str,
) -> Result<command_group::AsyncGroupChild, ExecutorError> {
let mut child = self.spawn(pool, task_id, worktree_path).await?;
// Take stderr pipe for OpenCode filtering
let stderr = child
.inner()
.stderr
.take()
.expect("Failed to take stderr from child process");
// Start OpenCode stderr filtering task
let pool_clone = pool.clone();
let worktree_path_clone = worktree_path.to_string();
tokio::spawn(stream_opencode_stderr_to_db(
stderr,
pool_clone,
attempt_id,
execution_process_id,
worktree_path_clone,
));
Ok(child)
}
fn normalize_logs(
&self,
logs: &str,
worktree_path: &str,
) -> Result<NormalizedConversation, String> {
// Reuse the same logic as the main SstOpencodeExecutor
let main_executor = SstOpencodeExecutor::new();
main_executor.normalize_logs(logs, worktree_path)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
executor::ActionType,
executors::sst_opencode::{
format_opencode_content_as_normalized_json, SstOpencodeExecutor,
},
};
// Test the actual format that comes from the database (normalized JSON entries)
#[test]
fn test_normalize_logs_with_database_format() {
let executor = SstOpencodeExecutor::new();
// This is what the database should contain after our streaming function processes it
let logs = r#"{"timestamp":"2025-07-16T18:04:00Z","entry_type":{"type":"tool_use","tool_name":"Read","action_type":{"action":"file_read","path":"hello.js"}},"content":"`hello.js`","metadata":{"filePath":"/path/to/repo/hello.js"}}
{"timestamp":"2025-07-16T18:04:01Z","entry_type":{"type":"assistant_message"},"content":"I'll read the hello.js file to see its current contents.","metadata":null}
{"timestamp":"2025-07-16T18:04:02Z","entry_type":{"type":"tool_use","tool_name":"bash","action_type":{"action":"command_run","command":"ls -la"}},"content":"`ls -la`","metadata":{"command":"ls -la"}}
{"timestamp":"2025-07-16T18:04:03Z","entry_type":{"type":"assistant_message"},"content":"The file exists and contains a hello world function.","metadata":null}"#;
let result = executor.normalize_logs(logs, "/path/to/repo").unwrap();
assert_eq!(result.entries.len(), 4);
// First entry: file read tool use
assert!(matches!(
result.entries[0].entry_type,
crate::executor::NormalizedEntryType::ToolUse { .. }
));
if let crate::executor::NormalizedEntryType::ToolUse {
tool_name,
action_type,
} = &result.entries[0].entry_type
{
assert_eq!(tool_name, "Read");
assert!(matches!(action_type, ActionType::FileRead { .. }));
}
assert_eq!(result.entries[0].content, "`hello.js`");
assert!(result.entries[0].timestamp.is_some());
// Second entry: assistant message
assert!(matches!(
result.entries[1].entry_type,
crate::executor::NormalizedEntryType::AssistantMessage
));
assert!(result.entries[1].content.contains("read the hello.js file"));
// Third entry: bash tool use
assert!(matches!(
result.entries[2].entry_type,
crate::executor::NormalizedEntryType::ToolUse { .. }
));
if let crate::executor::NormalizedEntryType::ToolUse {
tool_name,
action_type,
} = &result.entries[2].entry_type
{
assert_eq!(tool_name, "bash");
assert!(matches!(action_type, ActionType::CommandRun { .. }));
}
// Fourth entry: assistant message
assert!(matches!(
result.entries[3].entry_type,
crate::executor::NormalizedEntryType::AssistantMessage
));
assert!(result.entries[3].content.contains("The file exists"));
}
#[test]
fn test_normalize_logs_with_session_id() {
let executor = SstOpencodeExecutor::new();
// Test session ID in JSON metadata - current implementation always returns None for session_id
let logs = r#"{"timestamp":"2025-07-16T18:04:00Z","entry_type":{"type":"assistant_message"},"content":"Session started","metadata":null,"session_id":"ses_abc123"}
{"timestamp":"2025-07-16T18:04:01Z","entry_type":{"type":"assistant_message"},"content":"Hello world","metadata":null}"#;
let result = executor.normalize_logs(logs, "/tmp").unwrap();
assert_eq!(result.session_id, None); // Session ID is stored directly in the database
assert_eq!(result.entries.len(), 2);
}
#[test]
fn test_normalize_logs_legacy_fallback() {
let executor = SstOpencodeExecutor::new();
// Current implementation doesn't handle legacy format - it only parses JSON entries
let logs = r#"INFO session=ses_legacy123 starting
| Read {"filePath":"/path/to/file.js"}
This is a plain assistant message"#;
let result = executor.normalize_logs(logs, "/tmp").unwrap();
// Session ID is always None in current implementation
assert_eq!(result.session_id, None);
// Current implementation skips non-JSON lines, so no entries will be parsed
assert_eq!(result.entries.len(), 0);
}
#[test]
fn test_format_opencode_content_as_normalized_json() {
let content = r#"| Read {"filePath":"/path/to/repo/hello.js"}
I'll read this file to understand its contents.
| bash {"command":"ls -la"}
The file listing shows several items."#;
let result = format_opencode_content_as_normalized_json(content, "/path/to/repo");
let lines: Vec<&str> = result
.split('\n')
.filter(|line| !line.trim().is_empty())
.collect();
// Should have 4 entries (2 tool uses + 2 assistant messages)
assert_eq!(lines.len(), 4);
// Parse all entries and verify unique timestamps
let mut timestamps = Vec::new();
for line in &lines {
let json: serde_json::Value = serde_json::from_str(line).unwrap();
let timestamp = json["timestamp"].as_str().unwrap().to_string();
timestamps.push(timestamp);
}
// Verify all timestamps are unique (no duplicates)
let mut unique_timestamps = timestamps.clone();
unique_timestamps.sort();
unique_timestamps.dedup();
assert_eq!(
timestamps.len(),
unique_timestamps.len(),
"All timestamps should be unique"
);
// Parse the first line (should be Read tool use)
let first_json: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
assert_eq!(first_json["entry_type"]["type"], "tool_use");
assert_eq!(first_json["entry_type"]["tool_name"], "Read");
assert_eq!(first_json["content"], "`hello.js`");
// Parse the second line (should be assistant message)
let second_json: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(second_json["entry_type"]["type"], "assistant_message");
assert!(second_json["content"]
.as_str()
.unwrap()
.contains("read this file"));
// Parse the third line (should be bash tool use)
let third_json: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
assert_eq!(third_json["entry_type"]["type"], "tool_use");
assert_eq!(third_json["entry_type"]["tool_name"], "bash");
assert_eq!(third_json["content"], "`ls -la`");
// Verify timestamps include microseconds for uniqueness
for timestamp in timestamps {
assert!(
timestamp.contains('.'),
"Timestamp should include microseconds: {}",
timestamp
);
}
}
#[test]
fn test_format_opencode_content_todo_operations() {
let content = r#"| TodoWrite {"todos":[{"id":"1","content":"Fix bug","status":"completed","priority":"high"},{"id":"2","content":"Add feature","status":"in_progress","priority":"medium"}]}"#;
let result = format_opencode_content_as_normalized_json(content, "/tmp");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["entry_type"]["type"], "tool_use");
assert_eq!(json["entry_type"]["tool_name"], "todowrite"); // Normalized from "TodoWrite"
assert_eq!(json["entry_type"]["action_type"]["action"], "other"); // Changed from task_create to other
// Should contain formatted todo list
let content_str = json["content"].as_str().unwrap();
assert!(content_str.contains("TODO List:"));
assert!(content_str.contains("✅ Fix bug (high)"));
assert!(content_str.contains("🔄 Add feature (medium)"));
}
#[test]
fn test_format_opencode_content_todo_tool() {
// Test the "Todo" tool (case-sensitive, different from todowrite/todoread)
let content = r#"| Todo {"todos":[{"id":"1","content":"Review code","status":"pending","priority":"high"},{"id":"2","content":"Write tests","status":"in_progress","priority":"low"}]}"#;
let result = format_opencode_content_as_normalized_json(content, "/tmp");
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(json["entry_type"]["type"], "tool_use");
assert_eq!(json["entry_type"]["tool_name"], "todowrite"); // Normalized from "Todo"
assert_eq!(json["entry_type"]["action_type"]["action"], "other"); // Changed from task_create to other
// Should contain formatted todo list with proper emojis
let content_str = json["content"].as_str().unwrap();
assert!(content_str.contains("TODO List:"));
assert!(content_str.contains("⏳ Review code (high)"));
assert!(content_str.contains("🔄 Write tests (low)"));
}
#[test]
fn test_opencode_filter_noise_detection() {
use crate::executors::sst_opencode::filter::OpenCodeFilter;
// Test noise detection
assert!(OpenCodeFilter::is_noise(""));
assert!(OpenCodeFilter::is_noise(" "));
assert!(OpenCodeFilter::is_noise("█▀▀█ █▀▀█ Banner"));
assert!(OpenCodeFilter::is_noise("@ anthropic/claude-sonnet-4"));
assert!(OpenCodeFilter::is_noise("~ https://opencode.ai/s/abc123"));
assert!(OpenCodeFilter::is_noise("DEBUG some debug info"));
assert!(OpenCodeFilter::is_noise("INFO session info"));
assert!(OpenCodeFilter::is_noise("┌─────────────────┐"));
// Test clean content detection (not noise)
assert!(!OpenCodeFilter::is_noise("| Read {\"file\":\"test.js\"}"));
assert!(!OpenCodeFilter::is_noise("Assistant response text"));
assert!(!OpenCodeFilter::is_noise("{\"type\":\"content\"}"));
assert!(!OpenCodeFilter::is_noise("session=abc123 started"));
assert!(!OpenCodeFilter::is_noise("Normal conversation text"));
}
#[test]
fn test_normalize_logs_edge_cases() {
let executor = SstOpencodeExecutor::new();
// Empty content
let result = executor.normalize_logs("", "/tmp").unwrap();
assert_eq!(result.entries.len(), 0);
// Only whitespace
let result = executor.normalize_logs(" \n\t\n ", "/tmp").unwrap();
assert_eq!(result.entries.len(), 0);
// Malformed JSON (current implementation skips invalid JSON)
let malformed = r#"{"timestamp":"2025-01-16T18:04:00Z","content":"incomplete"#;
let result = executor.normalize_logs(malformed, "/tmp").unwrap();
assert_eq!(result.entries.len(), 0); // Current implementation skips invalid JSON
// Mixed valid and invalid JSON
let mixed = r#"{"timestamp":"2025-01-16T18:04:00Z","entry_type":{"type":"assistant_message"},"content":"Valid entry","metadata":null}
Invalid line that's not JSON
{"timestamp":"2025-01-16T18:04:01Z","entry_type":{"type":"assistant_message"},"content":"Another valid entry","metadata":null}"#;
let result = executor.normalize_logs(mixed, "/tmp").unwrap();
assert_eq!(result.entries.len(), 2); // Only valid JSON entries are parsed
}
#[test]
fn test_ansi_code_stripping() {
use crate::executors::sst_opencode::filter::OpenCodeFilter;
// Test ANSI escape sequence removal
let ansi_text = "\x1b[31mRed text\x1b[0m normal text";
let cleaned = OpenCodeFilter::strip_ansi_codes(ansi_text);
assert_eq!(cleaned, "Red text normal text");
// Test unicode escape sequences
let unicode_ansi = "Text with \\u001b[32mgreen\\u001b[0m color";
let cleaned = OpenCodeFilter::strip_ansi_codes(unicode_ansi);
assert_eq!(cleaned, "Text with green color");
// Test text without ANSI codes (unchanged)
let plain_text = "Regular text without codes";
let cleaned = OpenCodeFilter::strip_ansi_codes(plain_text);
assert_eq!(cleaned, plain_text);
}
}

View File

@@ -0,0 +1,184 @@
use lazy_static::lazy_static;
use regex::Regex;
lazy_static! {
static ref OPENCODE_LOG_REGEX: Regex = Regex::new(r"^(INFO|DEBUG|WARN|ERROR)\s+.*").unwrap();
static ref SESSION_ID_REGEX: Regex = Regex::new(r".*\b(id|session|sessionID)=([^ ]+)").unwrap();
static ref TOOL_USAGE_REGEX: Regex = Regex::new(r"^\|\s*([a-zA-Z]+)\s*(.*)").unwrap();
static ref NPM_WARN_REGEX: Regex = Regex::new(r"^npm warn .*").unwrap();
}
/// Filter for OpenCode stderr output
pub struct OpenCodeFilter;
impl OpenCodeFilter {
/// Check if a line should be skipped as noise
pub fn is_noise(line: &str) -> bool {
let trimmed = line.trim();
// Empty lines are noise
if trimmed.is_empty() {
return true;
}
// Strip ANSI escape codes for analysis
let cleaned = Self::strip_ansi_codes(trimmed);
let cleaned_trim = cleaned.trim();
// Skip tool calls - they are NOT noise
if TOOL_USAGE_REGEX.is_match(cleaned_trim) {
return false;
}
// OpenCode log lines are noise (includes session logs)
if is_opencode_log_line(cleaned_trim) {
return true;
}
if NPM_WARN_REGEX.is_match(cleaned_trim) {
return true;
}
// Spinner glyphs
if cleaned_trim.len() == 1 && "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏".contains(cleaned_trim) {
return true;
}
// Banner lines containing block glyphs (Unicode Block Elements range)
if cleaned_trim
.chars()
.any(|c| ('\u{2580}'..='\u{259F}').contains(&c))
{
return true;
}
// UI/stats frames using Box Drawing glyphs (U+2500-257F)
if cleaned_trim
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c))
{
return true;
}
// Model banner (@ with spaces)
if cleaned_trim.starts_with("@ ") {
return true;
}
// Share link
if cleaned_trim.starts_with("~") && cleaned_trim.contains("https://opencode.ai/s/") {
return true;
}
// Everything else (assistant messages) is NOT noise
false
}
pub fn is_stderr(_line: &str) -> bool {
false
}
/// Strip ANSI escape codes from text (conservative)
pub fn strip_ansi_codes(text: &str) -> String {
// Handle both unicode escape sequences and raw ANSI codes
let result = text.replace("\\u001b", "\x1b");
let mut cleaned = String::new();
let mut chars = result.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\x1b' {
// Skip ANSI escape sequence
if chars.peek() == Some(&'[') {
chars.next(); // consume '['
// Skip until we find a letter (end of ANSI sequence)
for next_ch in chars.by_ref() {
if next_ch.is_ascii_alphabetic() {
break;
}
}
}
} else {
cleaned.push(ch);
}
}
cleaned
}
}
/// Detect if a line is an OpenCode log line format using regex
pub fn is_opencode_log_line(line: &str) -> bool {
OPENCODE_LOG_REGEX.is_match(line)
}
/// Parse session_id from OpenCode log lines
pub fn parse_session_id_from_line(line: &str) -> Option<String> {
// Only apply to OpenCode log lines
if !is_opencode_log_line(line) {
return None;
}
// Try regex for session ID extraction from service=session logs
if let Some(captures) = SESSION_ID_REGEX.captures(line) {
if let Some(id) = captures.get(2) {
return Some(id.as_str().to_string());
}
}
None
}
/// Get the tool usage regex for parsing tool patterns
pub fn tool_usage_regex() -> &'static Regex {
&TOOL_USAGE_REGEX
}
#[cfg(test)]
mod tests {
#[test]
fn test_session_id_extraction() {
use crate::executors::sst_opencode::filter::parse_session_id_from_line;
// Test session ID extraction from session= format (only works on OpenCode log lines)
assert_eq!(
parse_session_id_from_line("INFO session=ses_abc123 starting"),
Some("ses_abc123".to_string())
);
assert_eq!(
parse_session_id_from_line("DEBUG id=debug_id process"),
Some("debug_id".to_string())
);
// Test lines without log prefix (should return None)
assert_eq!(
parse_session_id_from_line("session=simple_id chatting"),
None
);
// Test no session ID
assert_eq!(parse_session_id_from_line("No session here"), None);
assert_eq!(parse_session_id_from_line(""), None);
}
#[test]
fn test_ansi_code_stripping() {
use crate::executors::sst_opencode::filter::OpenCodeFilter;
// Test ANSI escape sequence removal
let ansi_text = "\x1b[31mRed text\x1b[0m normal text";
let cleaned = OpenCodeFilter::strip_ansi_codes(ansi_text);
assert_eq!(cleaned, "Red text normal text");
// Test unicode escape sequences
let unicode_ansi = "Text with \\u001b[32mgreen\\u001b[0m color";
let cleaned = OpenCodeFilter::strip_ansi_codes(unicode_ansi);
assert_eq!(cleaned, "Text with green color");
// Test text without ANSI codes (unchanged)
let plain_text = "Regular text without codes";
let cleaned = OpenCodeFilter::strip_ansi_codes(plain_text);
assert_eq!(cleaned, plain_text);
}
}

View File

@@ -0,0 +1,139 @@
use serde_json::{json, Value};
use crate::utils::path::make_path_relative;
/// Normalize tool names to match frontend expectations for purple box styling
pub fn normalize_tool_name(tool_name: &str) -> String {
match tool_name {
"Todo" => "todowrite".to_string(), // Generic TODO tool → todowrite
"TodoWrite" => "todowrite".to_string(),
"TodoRead" => "todoread".to_string(),
_ => tool_name.to_string(),
}
}
/// Helper function to determine action type for tool usage
pub fn determine_action_type(tool_name: &str, input: &Value, worktree_path: &str) -> Value {
match tool_name.to_lowercase().as_str() {
"read" => {
if let Some(file_path) = input.get("filePath").and_then(|p| p.as_str()) {
json!({
"action": "file_read",
"path": make_path_relative(file_path, worktree_path)
})
} else {
json!({"action": "other", "description": "File read operation"})
}
}
"write" | "edit" => {
if let Some(file_path) = input.get("filePath").and_then(|p| p.as_str()) {
json!({
"action": "file_write",
"path": make_path_relative(file_path, worktree_path)
})
} else {
json!({"action": "other", "description": "File write operation"})
}
}
"bash" => {
if let Some(command) = input.get("command").and_then(|c| c.as_str()) {
json!({"action": "command_run", "command": command})
} else {
json!({"action": "other", "description": "Command execution"})
}
}
"grep" => {
if let Some(pattern) = input.get("pattern").and_then(|p| p.as_str()) {
json!({"action": "search", "query": pattern})
} else {
json!({"action": "other", "description": "Search operation"})
}
}
"todowrite" | "todoread" => {
json!({"action": "other", "description": "TODO list management"})
}
_ => json!({"action": "other", "description": format!("Tool: {}", tool_name)}),
}
}
/// Helper function to generate concise content for tool usage
pub fn generate_tool_content(tool_name: &str, input: &Value, worktree_path: &str) -> String {
match tool_name.to_lowercase().as_str() {
"read" => {
if let Some(file_path) = input.get("filePath").and_then(|p| p.as_str()) {
format!("`{}`", make_path_relative(file_path, worktree_path))
} else {
"Read file".to_string()
}
}
"write" | "edit" => {
if let Some(file_path) = input.get("filePath").and_then(|p| p.as_str()) {
format!("`{}`", make_path_relative(file_path, worktree_path))
} else {
"Write file".to_string()
}
}
"bash" => {
if let Some(command) = input.get("command").and_then(|c| c.as_str()) {
format!("`{}`", command)
} else {
"Execute command".to_string()
}
}
"todowrite" | "todoread" => generate_todo_content(input),
_ => format!("`{}`", tool_name),
}
}
/// Generate formatted content for TODO tools
fn generate_todo_content(input: &Value) -> String {
// Extract todo list from input to show actual todos
if let Some(todos) = input.get("todos").and_then(|t| t.as_array()) {
let mut todo_items = Vec::new();
for todo in todos {
if let Some(content) = todo.get("content").and_then(|c| c.as_str()) {
let status = todo
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("pending");
let status_emoji = match status {
"completed" => "",
"in_progress" => "🔄",
"pending" | "todo" => "",
_ => "📝",
};
let priority = todo
.get("priority")
.and_then(|p| p.as_str())
.unwrap_or("medium");
todo_items.push(format!("{} {} ({})", status_emoji, content, priority));
}
}
if !todo_items.is_empty() {
format!("TODO List:\n{}", todo_items.join("\n"))
} else {
"Managing TODO list".to_string()
}
} else {
"Managing TODO list".to_string()
}
}
#[cfg(test)]
mod tests {
#[test]
fn test_normalize_tool_name() {
use crate::executors::sst_opencode::tools::normalize_tool_name;
// Test TODO tool normalization
assert_eq!(normalize_tool_name("Todo"), "todowrite");
assert_eq!(normalize_tool_name("TodoWrite"), "todowrite");
assert_eq!(normalize_tool_name("TodoRead"), "todoread");
// Test other tools remain unchanged
assert_eq!(normalize_tool_name("Read"), "Read");
assert_eq!(normalize_tool_name("Write"), "Write");
assert_eq!(normalize_tool_name("bash"), "bash");
assert_eq!(normalize_tool_name("SomeOtherTool"), "SomeOtherTool");
}
}

View File

@@ -623,6 +623,7 @@ impl ProcessService {
Some("amp") => crate::executor::ExecutorConfig::Amp,
Some("gemini") => crate::executor::ExecutorConfig::Gemini,
Some("charm-opencode") => crate::executor::ExecutorConfig::CharmOpencode,
Some("sst-opencode") => crate::executor::ExecutorConfig::SstOpencode,
_ => crate::executor::ExecutorConfig::Echo, // Default for "echo" or None
}
}
@@ -744,7 +745,7 @@ impl ProcessService {
} => {
use crate::executors::{
AmpFollowupExecutor, CCRFollowupExecutor, CharmOpencodeFollowupExecutor,
ClaudeFollowupExecutor, GeminiFollowupExecutor,
ClaudeFollowupExecutor, GeminiFollowupExecutor, SstOpencodeFollowupExecutor,
};
let executor: Box<dyn crate::executor::Executor> = match config {
@@ -803,6 +804,16 @@ impl ProcessService {
return Err(TaskAttemptError::TaskNotFound); // No session ID for followup
}
}
crate::executor::ExecutorConfig::SstOpencode => {
if let Some(sid) = session_id {
Box::new(SstOpencodeFollowupExecutor::new(
sid.clone(),
prompt.clone(),
))
} else {
return Err(TaskAttemptError::TaskNotFound); // No session ID for followup
}
}
crate::executor::ExecutorConfig::SetupScript { .. } => {
// Setup scripts don't support followup, use regular setup script
config.create_executor()

View File

@@ -2,6 +2,7 @@ use std::{env, sync::OnceLock};
use directories::ProjectDirs;
pub mod path;
pub mod shell;
pub mod text;
pub mod worktree_manager;

96
backend/src/utils/path.rs Normal file
View File

@@ -0,0 +1,96 @@
use std::path::Path;
/// Convert absolute paths to relative paths based on worktree path
/// This is a robust implementation that handles symlinks and edge cases
pub fn make_path_relative(path: &str, worktree_path: &str) -> String {
let path_obj = Path::new(path);
let worktree_path_obj = Path::new(worktree_path);
tracing::debug!("Making path relative: {} -> {}", path, worktree_path);
// If path is already relative, return as is
if path_obj.is_relative() {
return path.to_string();
}
// Try to make path relative to the worktree path
match path_obj.strip_prefix(worktree_path_obj) {
Ok(relative_path) => {
let result = relative_path.to_string_lossy().to_string();
tracing::debug!("Successfully made relative: '{}' -> '{}'", path, result);
result
}
Err(_) => {
// Handle symlinks by resolving canonical paths
let canonical_path = std::fs::canonicalize(path);
let canonical_worktree = std::fs::canonicalize(worktree_path);
match (canonical_path, canonical_worktree) {
(Ok(canon_path), Ok(canon_worktree)) => {
tracing::debug!(
"Trying canonical path resolution: '{}' -> '{}', '{}' -> '{}'",
path,
canon_path.display(),
worktree_path,
canon_worktree.display()
);
match canon_path.strip_prefix(&canon_worktree) {
Ok(relative_path) => {
let result = relative_path.to_string_lossy().to_string();
tracing::debug!(
"Successfully made relative with canonical paths: '{}' -> '{}'",
path,
result
);
result
}
Err(e) => {
tracing::warn!(
"Failed to make canonical path relative: '{}' relative to '{}', error: {}, returning original",
canon_path.display(),
canon_worktree.display(),
e
);
path.to_string()
}
}
}
_ => {
tracing::debug!(
"Could not canonicalize paths (paths may not exist): '{}', '{}', returning original",
path,
worktree_path
);
path.to_string()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_make_path_relative() {
// Test with relative path (should remain unchanged)
assert_eq!(
make_path_relative("src/main.rs", "/tmp/test-worktree"),
"src/main.rs"
);
// Test with absolute path (should become relative if possible)
let test_worktree = "/tmp/test-worktree";
let absolute_path = format!("{}/src/main.rs", test_worktree);
let result = make_path_relative(&absolute_path, test_worktree);
assert_eq!(result, "src/main.rs");
// Test with path outside worktree (should return original)
assert_eq!(
make_path_relative("/other/path/file.js", "/tmp/test-worktree"),
"/other/path/file.js"
);
}
}

View File

@@ -50,7 +50,9 @@ export function McpServers() {
const defaultConfig =
executorType === 'amp'
? '{\n "amp.mcpServers": {\n }\n}'
: '{\n "mcpServers": {\n }\n}';
: executorType === 'sst-opencode'
? '{\n "mcp": {\n }, "$schema": "https://opencode.ai/config.json"\n}'
: '{\n "mcpServers": {\n }\n}';
setMcpServers(defaultConfig);
setMcpConfigPath('');
@@ -67,6 +69,11 @@ export function McpServers() {
if (executorType === 'amp') {
// For AMP, use the amp.mcpServers structure
fullConfig = { 'amp.mcpServers': servers };
} else if (executorType === 'sst-opencode') {
fullConfig = {
mcp: servers,
$schema: 'https://opencode.ai/config.json',
};
} else {
// For other executors, use the standard mcpServers structure
fullConfig = { mcpServers: servers };
@@ -110,6 +117,10 @@ export function McpServers() {
'AMP configuration must contain an "amp.mcpServers" object'
);
}
} else if (selectedMcpExecutor === 'sst-opencode') {
if (!config.mcp || typeof config.mcp !== 'object') {
setMcpError('Configuration must contain an "mcp" object');
}
} else {
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
setMcpError('Configuration must contain an "mcpServers" object');
@@ -129,10 +140,17 @@ export function McpServers() {
const existingConfig = mcpServers.trim() ? JSON.parse(mcpServers) : {};
// Always use production MCP installation instructions
const vibeKanbanConfig = {
command: 'npx',
args: ['-y', 'vibe-kanban', '--mcp'],
};
const vibeKanbanConfig =
selectedMcpExecutor === 'sst-opencode'
? {
type: 'local',
command: ['npx', '-y', 'vibe-kanban', '--mcp'],
enabled: true,
}
: {
command: 'npx',
args: ['-y', 'vibe-kanban', '--mcp'],
};
// Add vibe_kanban to the existing configuration
let updatedConfig;
@@ -144,6 +162,14 @@ export function McpServers() {
vibe_kanban: vibeKanbanConfig,
},
};
} else if (selectedMcpExecutor === 'sst-opencode') {
updatedConfig = {
...existingConfig,
mcp: {
...(existingConfig.mcp || {}),
vibe_kanban: vibeKanbanConfig,
},
};
} else {
updatedConfig = {
...existingConfig,
@@ -189,6 +215,12 @@ export function McpServers() {
}
// Extract just the inner servers object for the API - backend will handle nesting
mcpServersConfig = fullConfig['amp.mcpServers'];
} else if (selectedMcpExecutor === 'sst-opencode') {
if (!fullConfig.mcp || typeof fullConfig.mcp !== 'object') {
throw new Error('Configuration must contain an "mcp" object');
}
// Extract just the mcp part for the API
mcpServersConfig = fullConfig.mcp;
} else {
if (
!fullConfig.mcpServers ||

View File

@@ -22,7 +22,7 @@ export type SoundConstants = { sound_files: Array<SoundFile>, sound_labels: Arra
export type ConfigConstants = { editor: EditorConstants, sound: SoundConstants, };
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "claude-plan" } | { "type": "amp" } | { "type": "gemini" } | { "type": "setup-script", script: string, } | { "type": "claude-code-router" } | { "type": "charm-opencode" };
export type ExecutorConfig = { "type": "echo" } | { "type": "claude" } | { "type": "claude-plan" } | { "type": "amp" } | { "type": "gemini" } | { "type": "setup-script", script: string, } | { "type": "claude-code-router" } | { "type": "charm-opencode" } | { "type": "sst-opencode" };
export type ExecutorConstants = { executor_types: Array<ExecutorConfig>, executor_labels: Array<string>, };
@@ -126,7 +126,8 @@ export const EXECUTOR_TYPES: string[] = [
"amp",
"gemini",
"charm-opencode",
"claude-code-router"
"claude-code-router",
"sst-opencode"
];
export const EDITOR_TYPES: EditorType[] = [
@@ -145,7 +146,8 @@ export const EXECUTOR_LABELS: Record<string, string> = {
"amp": "Amp",
"gemini": "Gemini",
"charm-opencode": "Charm Opencode",
"claude-code-router": "Claude Code Router"
"claude-code-router": "Claude Code Router",
"sst-opencode": "SST Opencode"
};
export const EDITOR_LABELS: Record<string, string> = {