初始化仓库
This commit is contained in:
@@ -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()
|
||||
}
|
||||
@@ -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]) + "..."
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user