初始化仓库

This commit is contained in:
2026-06-02 23:14:41 +08:00
commit 0bc3f02670
520 changed files with 191097 additions and 0 deletions
+717
View File
@@ -0,0 +1,717 @@
package opencode
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
)
func init() {
core.RegisterAgent("opencode", New)
}
// Agent drives the OpenCode CLI in headless mode using `opencode run --format json`.
//
// Modes:
// - "default": standard mode
// - "yolo": auto mode (opencode run is auto by default in non-interactive mode)
type Agent struct {
workDir string
model string
mode string
cmd string // CLI binary name, default "opencode"
providers []core.ProviderConfig
activeIdx int
sessionEnv []string
modelCachePath string
persistentModelCache *opencodePersistentModelCache
refreshingModelCache bool
mu sync.RWMutex
}
type opencodePersistentModelCache struct {
Models []core.ModelOption `json:"models"`
UpdatedAt time.Time `json:"updated_at"`
ProviderKey string `json:"provider_key,omitempty"`
ContextKey string `json:"context_key,omitempty"`
}
type opencodeModelDiscoverySnapshot struct {
cmd string
workDir string
providerEnv []string
providerKey string
cachePath string
}
func New(opts map[string]any) (core.Agent, error) {
workDir, _ := opts["work_dir"].(string)
if workDir == "" {
workDir = "."
}
model, _ := opts["model"].(string)
mode, _ := opts["mode"].(string)
mode = normalizeMode(mode)
cmd, _ := opts["cmd"].(string)
if cmd == "" {
cmd = "opencode"
}
ccDataDir, _ := opts["cc_data_dir"].(string)
ccProject, _ := opts["cc_project"].(string)
modelCachePath := opencodeProjectModelCachePath(ccDataDir, ccProject)
persistentModelCache, err := loadOpencodePersistentModelCache(modelCachePath)
if err != nil {
slog.Warn("opencode: load persistent model cache failed", "path", modelCachePath, "err", err)
}
if _, err := exec.LookPath(cmd); err != nil {
return nil, fmt.Errorf("opencode: %q CLI not found in PATH, install from: https://github.com/opencode-ai/opencode", cmd)
}
return &Agent{
workDir: workDir,
model: model,
mode: mode,
cmd: cmd,
activeIdx: -1,
modelCachePath: modelCachePath,
persistentModelCache: persistentModelCache,
}, nil
}
func opencodeProjectModelCachePath(dataDir, project string) string {
if dataDir == "" || project == "" {
return ""
}
prefix := sanitizeProjectCacheComponent(project)
if prefix == "" {
prefix = "project"
}
hash := sha256.Sum256([]byte(project))
fileName := fmt.Sprintf("%s-%s.opencode-models.json", prefix, hex.EncodeToString(hash[:8]))
return filepath.Join(dataDir, "projects", fileName)
}
func sanitizeProjectCacheComponent(project string) string {
project = strings.TrimSpace(strings.ToLower(project))
if project == "" {
return ""
}
var b strings.Builder
b.Grow(len(project))
lastDash := false
for _, r := range project {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
lastDash = false
continue
}
if !lastDash {
b.WriteByte('-')
lastDash = true
}
}
return strings.Trim(b.String(), "-")
}
func loadOpencodePersistentModelCache(path string) (*opencodePersistentModelCache, error) {
if path == "" {
return nil, nil
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var cache opencodePersistentModelCache
if err := json.Unmarshal(data, &cache); err != nil {
return nil, err
}
cache.Models = normalizeModelOptions(cache.Models)
if len(cache.Models) == 0 {
return nil, nil
}
return &cache, nil
}
func normalizeModelOptions(models []core.ModelOption) []core.ModelOption {
if len(models) == 0 {
return nil
}
seen := make(map[string]struct{}, len(models))
normalized := make([]core.ModelOption, 0, len(models))
for _, model := range models {
model.Name = strings.TrimSpace(model.Name)
if model.Name == "" {
continue
}
if _, dup := seen[model.Name]; dup {
continue
}
seen[model.Name] = struct{}{}
normalized = append(normalized, model)
}
if len(normalized) == 0 {
return nil
}
sort.Slice(normalized, func(i, j int) bool {
return normalized[i].Name < normalized[j].Name
})
return normalized
}
func normalizeMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "yolo", "auto", "force", "bypasspermissions":
return "yolo"
default:
return "default"
}
}
func (a *Agent) Name() string { return "opencode" }
func (a *Agent) SetWorkDir(dir string) {
a.mu.Lock()
defer a.mu.Unlock()
a.workDir = dir
slog.Info("opencode: work_dir changed", "work_dir", dir)
}
func (a *Agent) GetWorkDir() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.workDir
}
func (a *Agent) SetModel(model string) {
a.mu.Lock()
defer a.mu.Unlock()
a.model = model
slog.Info("opencode: model changed", "model", model)
}
func (a *Agent) GetModel() string {
a.mu.Lock()
defer a.mu.Unlock()
return core.GetProviderModel(a.providers, a.activeIdx, a.model)
}
func (a *Agent) configuredModels() []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
return core.GetProviderModels(a.providers, a.activeIdx)
}
func (a *Agent) configuredModelsForSnapshot(snapshot opencodeModelDiscoverySnapshot) []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
if len(a.providers) == 0 {
return nil
}
for i := range a.providers {
if providerCacheKey(a.providers[i]) != snapshot.providerKey {
continue
}
return core.GetProviderModels(a.providers, i)
}
return nil
}
func (a *Agent) activeProviderKeyLocked() string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return ""
}
return providerCacheKey(a.providers[a.activeIdx])
}
func providerCacheKey(p core.ProviderConfig) string {
h := sha256.New()
mustWriteProviderSignaturePart(h, "name", p.Name)
mustWriteProviderSignaturePart(h, "base_url", p.BaseURL)
mustWriteProviderSignaturePart(h, "model", p.Model)
mustWriteProviderSignaturePart(h, "api_key", p.APIKey)
keys := make([]string, 0, len(p.Env))
for k := range p.Env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
mustWriteProviderSignaturePart(h, "env:"+k, p.Env[k])
}
return hex.EncodeToString(h.Sum(nil)[:16])
}
func mustWriteProviderSignaturePart(w io.Writer, key, value string) {
if err := writeProviderSignaturePart(w, key, value); err != nil {
panic(fmt.Sprintf("write provider signature: %v", err))
}
}
func writeProviderSignaturePart(w io.Writer, key, value string) error {
if _, err := io.WriteString(w, key); err != nil {
return err
}
if _, err := io.WriteString(w, "\x00"); err != nil {
return err
}
if _, err := io.WriteString(w, value); err != nil {
return err
}
if _, err := io.WriteString(w, "\x00"); err != nil {
return err
}
return nil
}
func (a *Agent) activeProviderKey() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.activeProviderKeyLocked()
}
func (a *Agent) modelDiscoverySnapshot() opencodeModelDiscoverySnapshot {
a.mu.RLock()
defer a.mu.RUnlock()
return opencodeModelDiscoverySnapshot{
cmd: a.cmd,
workDir: a.workDir,
providerEnv: append([]string(nil), a.providerEnvLocked()...),
providerKey: a.activeProviderKeyLocked(),
cachePath: a.modelCachePath,
}
}
func modelDiscoveryContextKey(snapshot opencodeModelDiscoverySnapshot) string {
h := sha256.New()
mustWriteProviderSignaturePart(h, "provider_key", snapshot.providerKey)
mustWriteProviderSignaturePart(h, "work_dir", snapshot.workDir)
return hex.EncodeToString(h.Sum(nil)[:16])
}
func (a *Agent) persistentModelsForSnapshot(snapshot opencodeModelDiscoverySnapshot) []core.ModelOption {
a.mu.RLock()
defer a.mu.RUnlock()
if a.persistentModelCache == nil || len(a.persistentModelCache.Models) == 0 {
return nil
}
if a.persistentModelCache.ProviderKey != snapshot.providerKey {
return nil
}
if a.persistentModelCache.ContextKey != "" && a.persistentModelCache.ContextKey != modelDiscoveryContextKey(snapshot) {
return nil
}
if a.persistentModelCache.ContextKey == "" && snapshot.workDir != "" {
return nil
}
models := make([]core.ModelOption, len(a.persistentModelCache.Models))
copy(models, a.persistentModelCache.Models)
return models
}
func (a *Agent) persistentModels() []core.ModelOption {
return a.persistentModelsForSnapshot(a.modelDiscoverySnapshot())
}
func (a *Agent) startPersistentModelRefresh(snapshot opencodeModelDiscoverySnapshot, allowColdStart bool) {
a.mu.Lock()
hasPersistentModels := a.persistentModelCache != nil && len(a.persistentModelCache.Models) > 0
if (!allowColdStart && !hasPersistentModels) || a.refreshingModelCache {
a.mu.Unlock()
return
}
a.refreshingModelCache = true
a.mu.Unlock()
go func() {
defer func() {
a.mu.Lock()
a.refreshingModelCache = false
a.mu.Unlock()
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
models := a.discoverModelsWithSnapshot(ctx, snapshot)
if len(models) == 0 {
return
}
if err := a.storePersistentModelCache(snapshot, models); err != nil {
slog.Warn("opencode: update persistent model cache failed", "path", snapshot.cachePath, "err", err)
}
}()
}
func (a *Agent) StartInitialModelRefresh() {
a.startPersistentModelRefresh(a.modelDiscoverySnapshot(), true)
}
func (a *Agent) storePersistentModelCache(snapshot opencodeModelDiscoverySnapshot, models []core.ModelOption) error {
models = normalizeModelOptions(models)
if len(models) == 0 {
return nil
}
cache := &opencodePersistentModelCache{
Models: models,
UpdatedAt: time.Now(),
ProviderKey: snapshot.providerKey,
ContextKey: modelDiscoveryContextKey(snapshot),
}
if snapshot.cachePath != "" {
if err := os.MkdirAll(filepath.Dir(snapshot.cachePath), 0o755); err != nil {
return err
}
data, err := json.Marshal(cache)
if err != nil {
return err
}
if err := core.AtomicWriteFile(snapshot.cachePath, data, 0o644); err != nil {
return err
}
}
a.mu.Lock()
a.persistentModelCache = cache
a.mu.Unlock()
return nil
}
func (a *Agent) discoverModelsWithSnapshot(ctx context.Context, snapshot opencodeModelDiscoverySnapshot) []core.ModelOption {
c := exec.CommandContext(ctx, snapshot.cmd, "models")
c.Dir = snapshot.workDir
if len(snapshot.providerEnv) > 0 {
c.Env = append(os.Environ(), snapshot.providerEnv...)
}
out, err := c.Output()
if err != nil {
slog.Debug("opencode: discoverModels failed", "err", err)
return nil
}
var models []core.ModelOption
for _, line := range strings.Split(string(out), "\n") {
models = append(models, core.ModelOption{Name: line})
}
models = normalizeModelOptions(models)
if len(models) == 0 {
slog.Debug("opencode: discoverModels: no models in output")
return nil
}
return models
}
func (a *Agent) AvailableModels(ctx context.Context) []core.ModelOption {
snapshot := a.modelDiscoverySnapshot()
if models := a.persistentModelsForSnapshot(snapshot); len(models) > 0 {
a.startPersistentModelRefresh(snapshot, false)
return models
}
if models := a.discoverModelsWithSnapshot(ctx, snapshot); len(models) > 0 {
if err := a.storePersistentModelCache(snapshot, models); err != nil {
slog.Warn("opencode: persist discovered model cache failed", "path", a.modelCachePath, "err", err)
}
return models
}
if models := a.configuredModelsForSnapshot(snapshot); len(models) > 0 {
return models
}
return []core.ModelOption{
{Name: "anthropic/claude-sonnet-4-20250514", Desc: "Claude Sonnet 4 (default)"},
{Name: "anthropic/claude-opus-4-20250514", Desc: "Claude Opus 4"},
{Name: "openai/gpt-4o", Desc: "GPT-4o"},
{Name: "openai/o3", Desc: "OpenAI o3"},
}
}
func (a *Agent) SetSessionEnv(env []string) {
a.mu.Lock()
defer a.mu.Unlock()
a.sessionEnv = env
}
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
a.mu.Lock()
model := a.model
mode := a.mode
cmd := a.cmd
workDir := a.workDir
extraEnv := a.providerEnvLocked()
extraEnv = append(extraEnv, a.sessionEnv...)
if a.activeIdx >= 0 && a.activeIdx < len(a.providers) {
if m := a.providers[a.activeIdx].Model; m != "" {
model = m
}
}
a.mu.Unlock()
return newOpencodeSession(ctx, cmd, workDir, model, mode, sessionID, extraEnv)
}
// ListSessions runs `opencode session list` and parses the JSON output.
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
a.mu.RLock()
cmd := a.cmd
workDir := a.workDir
a.mu.RUnlock()
return listOpencodeSessions(cmd, workDir)
}
func (a *Agent) Stop() error { return nil }
// DeleteSession implements core.SessionDeleter via `opencode session delete <id>`.
func (a *Agent) DeleteSession(_ context.Context, sessionID string) error {
a.mu.RLock()
cmd := a.cmd
workDir := a.workDir
a.mu.RUnlock()
c := exec.Command(cmd, "session", "delete", sessionID)
c.Dir = workDir
if out, err := c.CombinedOutput(); err != nil {
return fmt.Errorf("opencode: delete session %s: %w: %s", sessionID, err, strings.TrimSpace(string(out)))
}
return nil
}
// -- ModeSwitcher --
func (a *Agent) SetMode(mode string) {
a.mu.Lock()
defer a.mu.Unlock()
a.mode = normalizeMode(mode)
slog.Info("opencode: mode changed", "mode", a.mode)
}
func (a *Agent) GetMode() string {
a.mu.Lock()
defer a.mu.Unlock()
return a.mode
}
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
return []core.PermissionModeInfo{
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard mode", DescZh: "标准模式"},
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Auto-approve all tool calls", DescZh: "自动批准所有工具调用"},
}
}
// -- ContextCompressor --
func (a *Agent) CompressCommand() string { return "/compact" }
// -- MemoryFileProvider --
func (a *Agent) ProjectMemoryFile() string {
a.mu.RLock()
workDir := a.workDir
a.mu.RUnlock()
absDir, err := filepath.Abs(workDir)
if err != nil {
absDir = workDir
}
return filepath.Join(absDir, "OPENCODE.md")
}
func (a *Agent) GlobalMemoryFile() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(homeDir, ".opencode", "OPENCODE.md")
}
// -- ProviderSwitcher --
func (a *Agent) SetProviders(providers []core.ProviderConfig) {
a.mu.Lock()
defer a.mu.Unlock()
a.providers = providers
}
func (a *Agent) SetActiveProvider(name string) bool {
a.mu.Lock()
defer a.mu.Unlock()
if name == "" {
a.activeIdx = -1
slog.Info("opencode: provider cleared")
return true
}
for i, p := range a.providers {
if p.Name == name {
a.activeIdx = i
slog.Info("opencode: provider switched", "provider", name)
return true
}
}
return false
}
func (a *Agent) GetActiveProvider() *core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
return &p
}
func (a *Agent) ListProviders() []core.ProviderConfig {
a.mu.Lock()
defer a.mu.Unlock()
result := make([]core.ProviderConfig, len(a.providers))
copy(result, a.providers)
return result
}
func (a *Agent) providerEnvLocked() []string {
if a.activeIdx < 0 || a.activeIdx >= len(a.providers) {
return nil
}
p := a.providers[a.activeIdx]
var env []string
if p.APIKey != "" {
env = append(env, "ANTHROPIC_API_KEY="+p.APIKey)
}
for k, v := range p.Env {
env = append(env, k+"="+v)
}
return env
}
// -- Session listing --
// opencodeSessionEntry represents a session from `opencode session list` output.
type opencodeSessionEntry struct {
ID string `json:"id"`
Title string `json:"title"`
Updated int64 `json:"updated"` // Unix timestamp in milliseconds
Created int64 `json:"created"`
}
func listOpencodeSessions(cmd, workDir string) ([]core.AgentSessionInfo, error) {
c := exec.Command(cmd, "session", "list", "--format", "json")
c.Dir = workDir
out, err := c.Output()
if err != nil {
return nil, fmt.Errorf("opencode: session list: %w", err)
}
var entries []opencodeSessionEntry
if err := json.Unmarshal(out, &entries); err != nil {
return nil, fmt.Errorf("opencode: parse session list: %w", err)
}
msgCounts := querySessionMessageCounts()
var sessions []core.AgentSessionInfo
for _, e := range entries {
sessions = append(sessions, core.AgentSessionInfo{
ID: e.ID,
Summary: e.Title,
MessageCount: msgCounts[e.ID],
ModifiedAt: time.UnixMilli(e.Updated),
})
}
return sessions, nil
}
// querySessionMessageCounts uses the sqlite3 CLI to read message counts from
// OpenCode's local database. Returns an empty map on any failure.
func querySessionMessageCounts() map[string]int {
dbPath := opencodeDBPath()
if dbPath == "" {
return nil
}
if _, err := os.Stat(dbPath); err != nil {
return nil
}
sqlite3, err := exec.LookPath("sqlite3")
if err != nil {
slog.Warn("opencode: sqlite3 CLI not found, message counts unavailable", "err", err)
return nil
}
out, err := exec.Command(sqlite3, dbPath,
"SELECT session_id, COUNT(*) FROM message GROUP BY session_id").Output()
if err != nil {
slog.Warn("opencode: sqlite3 query failed", "db_path", dbPath, "err", err)
return nil
}
counts := make(map[string]int)
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.SplitN(line, "|", 2)
if len(parts) != 2 {
continue
}
var n int
if _, err := fmt.Sscanf(parts[1], "%d", &n); err == nil {
counts[parts[0]] = n
}
}
return counts
}
func opencodeDBPath() string {
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
return filepath.Join(xdg, "opencode", "opencode.db")
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".local", "share", "opencode", "opencode.db")
}
func (a *Agent) GetSessionTitle(sessionID string) string {
return querySessionTitle(sessionID)
}
func querySessionTitle(sessionID string) string {
dbPath := opencodeDBPath()
if dbPath == "" {
return ""
}
if _, err := os.Stat(dbPath); err != nil {
return ""
}
sqlite3, err := exec.LookPath("sqlite3")
if err != nil {
return ""
}
escaped := strings.ReplaceAll(sessionID, "'", "''")
query := fmt.Sprintf("SELECT title FROM session WHERE id = '%s' LIMIT 1", escaped)
out, err := exec.Command(sqlite3, dbPath, query).Output()
if err != nil {
return ""
}
title := strings.TrimSpace(string(out))
return title
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
package opencode
import (
"context"
"path/filepath"
"sync"
"testing"
)
// TestOpencodeAgent_WorkDirRaceFreeReaders pins the bug where
// ListSessions and ProjectMemoryFile read a.workDir (and a.cmd, in
// ListSessions' case) without holding a.mu, while SetWorkDir writes
// a.workDir under the lock. Run with -race to detect the data race;
// with the production fix the test stays clean.
func TestOpencodeAgent_WorkDirRaceFreeReaders(t *testing.T) {
dir := t.TempDir()
a := &Agent{workDir: dir, cmd: "opencode"}
var wg sync.WaitGroup
for i := 0; i < 30; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
if i%2 == 0 {
a.SetWorkDir(filepath.Join(dir, "a"))
} else {
a.SetWorkDir(filepath.Join(dir, "b"))
}
}(i)
}
for i := 0; i < 30; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// ListSessions execs `opencode session list`; with a
// non-existent binary it returns an error immediately.
// The race detector still observes the unlocked field
// reads before the exec attempt.
_, _ = a.ListSessions(context.Background())
}()
wg.Add(1)
go func() {
defer wg.Done()
_ = a.ProjectMemoryFile()
}()
}
wg.Wait()
}
+524
View File
@@ -0,0 +1,524 @@
package opencode
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// opencodeSession manages multi-turn conversations with the OpenCode CLI.
// Each Send() launches a new `opencode run --format json` process
// with --session for conversation continuity.
type opencodeSession struct {
cmd string
workDir string
model string
mode string
extraEnv []string
events chan core.Event
chatID atomic.Value // stores string — OpenCode session ID
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
expectingContinue atomic.Bool // true when compaction_continue received, waiting for next step
resultSent atomic.Bool // true when EventResult has been sent for this turn
}
func newOpencodeSession(ctx context.Context, cmd, workDir, model, mode, resumeID string, extraEnv []string) (*opencodeSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
s := &opencodeSession{
cmd: cmd,
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
s.alive.Store(true)
if resumeID != "" && resumeID != core.ContinueSession {
s.chatID.Store(resumeID)
}
return s, nil
}
func (s *opencodeSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if len(files) > 0 {
filePaths := core.SaveFilesToDisk(s.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
}
prompt, imagePaths, err := s.stageImages(prompt, images)
if err != nil {
return err
}
if !s.alive.Load() {
return fmt.Errorf("session is closed")
}
s.resultSent.Store(false)
s.expectingContinue.Store(false)
chatID := s.CurrentSessionID()
isResume := chatID != ""
args := s.buildRunArgs(prompt, imagePaths, chatID)
slog.Debug("opencodeSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(s.ctx, s.cmd, args...)
cmd.Dir = s.workDir
env := os.Environ()
if len(s.extraEnv) > 0 {
env = core.MergeEnv(env, s.extraEnv)
}
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("opencodeSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
cmd.Stdin = strings.NewReader(prompt)
if err := cmd.Start(); err != nil {
return fmt.Errorf("opencodeSession: start: %w", err)
}
s.wg.Add(1)
go s.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (s *opencodeSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) {
if len(images) == 0 {
return prompt, nil, nil
}
imgDir := filepath.Join(s.workDir, ".cc-connect", "images")
if err := os.MkdirAll(imgDir, 0o755); err != nil {
return "", nil, fmt.Errorf("opencodeSession: create image dir: %w", err)
}
imagePaths := make([]string, 0, len(images))
for i, img := range images {
ext := opencodeImageExt(img.MimeType)
fname := fmt.Sprintf("img_%d_%d%s", time.Now().UnixMilli(), i, ext)
fpath := filepath.Join(imgDir, fname)
if err := os.WriteFile(fpath, img.Data, 0o644); err != nil {
return "", nil, fmt.Errorf("opencodeSession: save image: %w", err)
}
imagePaths = append(imagePaths, fpath)
}
if prompt == "" {
prompt = "Please analyze the attached image(s)."
}
return prompt, imagePaths, nil
}
func opencodeImageExt(mimeType string) string {
switch mimeType {
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".png"
}
}
func (s *opencodeSession) buildRunArgs(prompt string, imagePaths []string, chatID string) []string {
args := []string{"run", "--format", "json"}
if chatID != "" {
args = append(args, "--session", chatID)
}
if s.model != "" {
args = append(args, "--model", s.model)
}
if s.workDir != "" {
args = append(args, "--dir", s.workDir)
}
// Enable thinking blocks.
args = append(args, "--thinking")
// In yolo/auto mode, skip permission prompts entirely so headless
// runs don't get stuck with auto-rejected external-directory ops.
if s.mode == "yolo" {
args = append(args, "--dangerously-skip-permissions")
}
for _, imagePath := range imagePaths {
if imagePath == "" {
continue
}
args = append(args, "--file", imagePath)
}
return args
}
func (s *opencodeSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer s.wg.Done()
defer func() { _ = cmd.Wait() }()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024)
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
var raw map[string]any
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("opencodeSession: non-JSON line", "line", line)
continue
}
s.handleEvent(raw)
}
if err := scanner.Err(); err != nil {
slog.Error("opencodeSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
return
}
stderrMsg := stderrBuf.String()
if stderrMsg != "" {
slog.Error("opencodeSession: process error", "stderr", truncate(stderrMsg, 500))
if strings.Contains(stderrMsg, "Session not found") {
s.chatID.Store("")
slog.Warn("opencodeSession: cleared stale session ID")
}
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case s.events <- evt:
case <-s.ctx.Done():
}
return
}
// Check if we received compaction_continue before readLoop ended.
// If so, OpenCode will continue with a new turn - do NOT send EventResult.
// The subsequent process will send its own EventResult when it finishes.
if s.expectingContinue.Load() {
slog.Info("opencodeSession: readLoop ended after compaction_continue, skipping EventResult", "session_id", s.CurrentSessionID())
s.expectingContinue.Store(false)
return
}
slog.Debug("opencodeSession: readLoop complete, sending fallback EventResult", "session_id", s.CurrentSessionID())
s.sendEventResult()
}
// OpenCode NDJSON event structure:
//
// { "type": "text|tool_use|reasoning|step_start|step_finish",
// "part": { "type": "text|tool|reasoning|step-start|step-finish", ... } }
func (s *opencodeSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "text":
s.handleText(raw)
case "tool_use":
s.handleToolUse(raw)
case "reasoning":
s.handleReasoning(raw)
case "step_start":
s.handleStepStart(raw)
case "step_finish":
s.handleStepFinish(raw)
case "error":
s.handleError(raw)
default:
b, _ := json.Marshal(raw)
slog.Debug("opencodeSession: unhandled event", "type", eventType, "raw", string(b))
}
}
func (s *opencodeSession) handleText(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
text, _ := part["text"].(string)
// Extract metadata and synthetic flags to identify compaction_continue
metadata, _ := part["metadata"].(map[string]any)
synthetic, _ := part["synthetic"].(bool)
// Check for compaction_continue: this is OpenCode's auto-continuation signal.
// When received, we should NOT send EventText to engine, but mark that we expect
// a continuation (next step_start will start a new turn without EventResult).
if synthetic && metadata != nil {
if cc, ok := metadata["compaction_continue"].(bool); ok && cc {
slog.Info("opencodeSession: compaction_continue detected, marking expectingContinue", "session_id", s.CurrentSessionID())
s.expectingContinue.Store(true)
// Do NOT send EventText - this is internal continuation signal
return
}
}
if text != "" {
evt := core.Event{Type: core.EventText, Content: text, Metadata: metadata, Synthetic: synthetic}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func (s *opencodeSession) handleToolUse(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
toolName, _ := part["tool"].(string)
state, _ := part["state"].(map[string]any)
status := ""
if state != nil {
status, _ = state["status"].(string)
}
// Extract tool input summary for display
input := extractToolInput(state)
if status == "completed" {
// OpenCode bundles call + result in one event; emit both for UI.
useEvt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case s.events <- useEvt:
case <-s.ctx.Done():
return
}
output, _ := state["output"].(string)
resultEvt := core.Event{Type: core.EventToolResult, ToolName: toolName, Content: truncate(output, 500)}
select {
case s.events <- resultEvt:
case <-s.ctx.Done():
return
}
} else {
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func extractToolInput(state map[string]any) string {
if state == nil {
return ""
}
// Prefer title as a concise description (e.g. "List files in current directory")
if title, ok := state["title"].(string); ok && title != "" {
return title
}
switch input := state["input"].(type) {
case string:
return input
case map[string]any:
// Use "description" or "command" fields if available
if desc, ok := input["description"].(string); ok && desc != "" {
return desc
}
if cmd, ok := input["command"].(string); ok && cmd != "" {
return cmd
}
b, _ := json.Marshal(input)
return truncate(string(b), 200)
}
return ""
}
func (s *opencodeSession) handleReasoning(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
if part == nil {
return
}
text, _ := part["text"].(string)
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
}
func (s *opencodeSession) handleError(raw map[string]any) {
errMsg := extractErrorMessage(raw)
slog.Error("opencodeSession: agent error", "error", errMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", errMsg)}
select {
case s.events <- evt:
case <-s.ctx.Done():
return
}
}
// extractErrorMessage tries to pull a human-readable message from various
// OpenCode error JSON shapes.
func extractErrorMessage(raw map[string]any) string {
// Shape: {"error": {"data": {"message": "..."}, "name": "..."}}
if errObj, ok := raw["error"].(map[string]any); ok {
if data, ok := errObj["data"].(map[string]any); ok {
if msg, ok := data["message"].(string); ok && msg != "" {
name, _ := errObj["name"].(string)
if name != "" {
return name + ": " + msg
}
return msg
}
}
if msg, ok := errObj["message"].(string); ok && msg != "" {
return msg
}
if name, ok := errObj["name"].(string); ok && name != "" {
return name
}
}
// Shape: {"error": "string message"}
if errStr, ok := raw["error"].(string); ok && errStr != "" {
return errStr
}
// Shape: {"part": {"error": "...", "message": "..."}}
if part, ok := raw["part"].(map[string]any); ok {
if msg, ok := part["error"].(string); ok && msg != "" {
return msg
}
if msg, ok := part["message"].(string); ok && msg != "" {
return msg
}
}
if msg, ok := raw["message"].(string); ok && msg != "" {
return msg
}
b, _ := json.Marshal(raw)
return string(b)
}
func (s *opencodeSession) handleStepStart(raw map[string]any) {
sessionID, _ := raw["sessionID"].(string)
if sessionID == "" {
part, _ := raw["part"].(map[string]any)
if part != nil {
sessionID, _ = part["sessionID"].(string)
}
}
if sessionID != "" {
s.chatID.Store(sessionID)
slog.Debug("opencodeSession: session started", "session_id", sessionID)
}
}
func (s *opencodeSession) handleStepFinish(raw map[string]any) {
part, _ := raw["part"].(map[string]any)
reason := ""
if part != nil {
reason, _ = part["reason"].(string)
}
slog.Debug("opencodeSession: step finished", "reason", reason, "session_id", s.CurrentSessionID())
if reason == "stop" {
s.sendEventResult()
}
}
func (s *opencodeSession) sendEventResult() {
if s.resultSent.Load() {
slog.Debug("opencodeSession: EventResult already sent, skipping", "session_id", s.CurrentSessionID())
return
}
s.resultSent.Store(true)
sid := s.CurrentSessionID()
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
select {
case s.events <- evt:
case <-s.ctx.Done():
}
}
// RespondPermission is a no-op — OpenCode handles permissions internally.
func (s *opencodeSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (s *opencodeSession) Events() <-chan core.Event {
return s.events
}
func (s *opencodeSession) CurrentSessionID() string {
v, _ := s.chatID.Load().(string)
return v
}
func (s *opencodeSession) Alive() bool {
return s.alive.Load()
}
func (s *opencodeSession) Close() error {
s.alive.Store(false)
s.cancel()
done := make(chan struct{})
go func() {
s.wg.Wait()
close(done)
}()
select {
case <-done:
close(s.events)
case <-time.After(8 * time.Second):
slog.Warn("opencodeSession: close timed out, abandoning wg.Wait")
}
return nil
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}
+246
View File
@@ -0,0 +1,246 @@
package opencode
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"sync/atomic"
"testing"
"github.com/chenhg5/cc-connect/core"
)
// TestOpencodeSessionEntry_Unmarshal verifies that OpenCode's
// `session list --format json` output can be correctly parsed.
//
// OpenCode returns `updated` and `created` as Unix timestamps in
// milliseconds (int64), not strings. This test prevents regression
// of the unmarshal error:
//
// json: cannot unmarshal number into Go struct field opencodeSessionEntry.updated of type string
func TestOpencodeSessionEntry_Unmarshal(t *testing.T) {
jsonData := `[
{
"id": "ses_2eb11bb11ffeYwQZOj25mlmGMc",
"title": "Test Session",
"updated": 1774174646445,
"created": 1774172652782,
"projectId": "b80385ead03e8b450bdb2016d434aad318f93c16",
"directory": "/path/to/project"
}
]`
var entries []opencodeSessionEntry
if err := json.Unmarshal([]byte(jsonData), &entries); err != nil {
t.Fatalf("Failed to unmarshal OpenCode session list: %v", err)
}
if len(entries) != 1 {
t.Fatalf("Expected 1 entry, got %d", len(entries))
}
e := entries[0]
if e.ID != "ses_2eb11bb11ffeYwQZOj25mlmGMc" {
t.Errorf("ID = %q, want %q", e.ID, "ses_2eb11bb11ffeYwQZOj25mlmGMc")
}
if e.Title != "Test Session" {
t.Errorf("Title = %q, want %q", e.Title, "Test Session")
}
if e.Updated != 1774174646445 {
t.Errorf("Updated = %d, want %d", e.Updated, 1774174646445)
}
if e.Created != 1774172652782 {
t.Errorf("Created = %d, want %d", e.Created, 1774172652782)
}
}
// TestNewOpencodeSession_ContinueSessionTreatedAsFresh verifies that
// the ContinueSession sentinel (__continue__) is not passed as a literal
// session ID to the CLI. This was fixed in PR #249.
func TestNewOpencodeSession_ContinueSessionTreatedAsFresh(t *testing.T) {
s, err := newOpencodeSession(context.Background(), "echo", "/tmp", "", "default", core.ContinueSession, nil)
if err != nil {
t.Fatalf("newOpencodeSession: %v", err)
}
defer s.Close()
if got := s.CurrentSessionID(); got != "" {
t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got)
}
}
func TestOpencodeSessionStageImages(t *testing.T) {
dir := t.TempDir()
s := &opencodeSession{workDir: dir}
prompt, imagePaths, err := s.stageImages("", []core.ImageAttachment{
{MimeType: "image/jpeg", Data: []byte{0xff, 0xd8, 0xff}},
{MimeType: "image/webp", Data: []byte("webp")},
})
if err != nil {
t.Fatalf("stageImages: %v", err)
}
if prompt != "Please analyze the attached image(s)." {
t.Fatalf("prompt = %q", prompt)
}
if len(imagePaths) != 2 {
t.Fatalf("imagePaths len = %d, want 2", len(imagePaths))
}
if filepath.Ext(imagePaths[0]) != ".jpg" {
t.Fatalf("first ext = %q, want .jpg", filepath.Ext(imagePaths[0]))
}
if filepath.Ext(imagePaths[1]) != ".webp" {
t.Fatalf("second ext = %q, want .webp", filepath.Ext(imagePaths[1]))
}
for _, path := range imagePaths {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected staged image %s: %v", path, err)
}
}
}
func TestOpencodeSessionBuildRunArgsIncludesImagesAsFiles(t *testing.T) {
s := &opencodeSession{workDir: "/repo", model: "provider/model"}
got := s.buildRunArgs("describe these images", []string{"/tmp/a.png", "/tmp/b.jpg"}, "ses_123")
want := []string{
"run", "--format", "json",
"--session", "ses_123",
"--model", "provider/model",
"--dir", "/repo",
"--thinking",
"--file", "/tmp/a.png",
"--file", "/tmp/b.jpg",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("args = %#v, want %#v", got, want)
}
}
// TestHandleStepStart_SessionIDFromTopLevel verifies that handleStepStart
// prefers the sessionID from the top-level JSON field when both top-level
// and part-level sessionID are present. This matches OpenCode's stdout format.
func TestHandleStepStart_SessionIDFromTopLevel(t *testing.T) {
jsonData := `{"type":"step_start","sessionID":"ses_top_level","part":{"sessionID":"ses_part_level"}}`
var raw map[string]any
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
s := &opencodeSession{}
s.handleStepStart(raw)
if got := s.CurrentSessionID(); got != "ses_top_level" {
t.Errorf("sessionID = %q, want %q (should prefer top-level)", got, "ses_top_level")
}
}
// TestHandleStepStart_SessionIDFromPart verifies that handleStepStart
// falls back to the sessionID inside part when top-level sessionID is absent.
func TestHandleStepStart_SessionIDFromPart(t *testing.T) {
jsonData := `{"type":"step_start","part":{"sessionID":"ses_part_level"}}`
var raw map[string]any
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
s := &opencodeSession{}
s.handleStepStart(raw)
if got := s.CurrentSessionID(); got != "ses_part_level" {
t.Errorf("sessionID = %q, want %q (should fallback to part)", got, "ses_part_level")
}
}
// TestHandleStepStopSendsEventResult verifies that handleStepFinish sends
// an EventResult when reason="stop", signaling turn completion to the engine.
func TestHandleStepStopSendsEventResult(t *testing.T) {
jsonData := `{"type":"step_finish","part":{"reason":"stop"}}`
var raw map[string]any
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx}
s.handleStepFinish(raw)
select {
case evt := <-s.events:
if evt.Type != core.EventResult {
t.Errorf("event type = %q, want EventResult", evt.Type)
}
if !evt.Done {
t.Errorf("event.Done = false, want true")
}
default:
t.Error("expected EventResult to be sent when reason=stop")
}
}
// TestHandleStepToolCallsNoEventResult verifies that handleStepFinish does NOT
// send EventResult when reason="tool-calls", allowing the agent to continue
// with subsequent tool execution steps.
func TestHandleStepToolCallsNoEventResult(t *testing.T) {
jsonData := `{"type":"step_finish","part":{"reason":"tool-calls"}}`
var raw map[string]any
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx}
s.handleStepFinish(raw)
select {
case evt := <-s.events:
t.Errorf("unexpected event sent when reason=tool-calls: %v", evt)
default:
}
}
// TestHandleStepDuplicateEventResultPrevented verifies that calling
// handleStepFinish multiple times with reason="stop" only sends one
// EventResult, preventing duplicate completion signals to the engine.
func TestHandleStepDuplicateEventResultPrevented(t *testing.T) {
jsonData := `{"type":"step_finish","part":{"reason":"stop"}}`
var raw map[string]any
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s := &opencodeSession{
events: make(chan core.Event, 2),
ctx: ctx,
resultSent: atomic.Bool{},
}
s.handleStepFinish(raw)
s.handleStepFinish(raw)
count := 0
for len(s.events) > 0 {
evt := <-s.events
if evt.Type == core.EventResult {
count++
}
}
if count != 1 {
t.Errorf("EventResult count = %d, want 1 (duplicate should be prevented)", count)
}
}
// verify Agent implements core.Agent
var _ core.Agent = (*Agent)(nil)