When the opencode process crashes (e.g. stale session ID causes 'Session not found'), readLoop sends a fallback EventResult before the deferred cmd.Wait() can detect and emit an EventError. The engine receives EventResult(Done:true) first, returns from the event loop, and the subsequent EventError is never consumed. The user sees an empty response with no error information. Fix by checking stderrBuf immediately after the scanner loop exits, before sending the fallback EventResult. If stderr contains errors (including 'Session not found'), emit EventError instead. cmd.Wait() remains in a defer for resource cleanup only. Engine uses a pattern table (agentErrorHandlers) to match known errors and show localized user-friendly messages with recovery hints. Co-authored-by: vibecoder <16832299+vibecoder@user.noreply.gitee.com>
471 lines
11 KiB
Go
471 lines
11 KiB
Go
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
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
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
|
|
|
|
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")
|
|
|
|
for _, imagePath := range imagePaths {
|
|
if imagePath == "" {
|
|
continue
|
|
}
|
|
args = append(args, "--file", imagePath)
|
|
}
|
|
|
|
// Append prompt as positional arg.
|
|
args = append(args, prompt)
|
|
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
|
|
}
|
|
|
|
sid := s.CurrentSessionID()
|
|
slog.Debug("opencodeSession: readLoop complete, sending fallback EventResult", "session_id", sid)
|
|
evt := core.Event{Type: core.EventResult, SessionID: sid, Done: true}
|
|
select {
|
|
case s.events <- evt:
|
|
case <-s.ctx.Done():
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
if text != "" {
|
|
evt := core.Event{Type: core.EventText, Content: text}
|
|
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) {
|
|
part, _ := raw["part"].(map[string]any)
|
|
if part == nil {
|
|
return
|
|
}
|
|
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, _ := part["reason"].(string)
|
|
slog.Debug("opencodeSession: step finished", "reason", reason, "session_id", s.CurrentSessionID())
|
|
}
|
|
|
|
// 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:
|
|
case <-time.After(8 * time.Second):
|
|
slog.Warn("opencodeSession: close timed out, abandoning wg.Wait")
|
|
}
|
|
close(s.events)
|
|
return nil
|
|
}
|
|
|
|
func truncate(s string, maxRunes int) string {
|
|
if utf8.RuneCountInString(s) <= maxRunes {
|
|
return s
|
|
}
|
|
return string([]rune(s)[:maxRunes]) + "..."
|
|
}
|