mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-06 13:40:51 +08:00
When opencode rejects a tool call in default mode (e.g. bash permission denied), it emits a tool_use event with status="error" then exits without generating any follow-up text. This caused the engine to fall through to the "(空响应)" / "(empty response)" placeholder since textParts was empty. Fix: when handleToolUse receives status="error" with a non-empty error message, emit an EventText containing the rejection reason. This ensures textParts is populated and the user sees why the command was blocked (e.g. "The user rejected permission to use this specific tool call.") rather than a silent empty response. Three scenarios handled: - Permission denied (default mode): error text surfaced ✓ - Tool completed successfully: no change ✓ - Error with no message: no spurious empty EventText ✓ To avoid permission rejections entirely, set mode="yolo" in config or configure opencode's permission settings to allow the required tools. Fixes #178 Co-authored-by: root <root@UYQQVGRAEKQKNQP> Co-authored-by: Cursor <cursoragent@cursor.com>
548 lines
14 KiB
Go
548 lines
14 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
|
|
agentName 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, agentName, resumeID string, extraEnv []string) (*opencodeSession, error) {
|
|
sessionCtx, cancel := context.WithCancel(ctx)
|
|
|
|
s := &opencodeSession{
|
|
cmd: cmd,
|
|
workDir: workDir,
|
|
model: model,
|
|
mode: mode,
|
|
agentName: agentName,
|
|
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.agentName != "" {
|
|
args = append(args, "--agent", s.agentName)
|
|
}
|
|
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
|
|
}
|
|
|
|
// When a tool call is rejected (e.g. permission denied in default mode),
|
|
// opencode exits without generating any follow-up text. Surface the rejection
|
|
// reason so the engine has something meaningful to send rather than "(空响应)".
|
|
// This covers the common case where the user has not configured tool permissions
|
|
// and needs guidance to use mode="yolo" or update opencode settings.
|
|
if status == "error" && state != nil {
|
|
errMsg, _ := state["error"].(string)
|
|
if errMsg != "" {
|
|
slog.Info("opencodeSession: tool rejected, surfacing error as text", "tool", toolName, "error", errMsg)
|
|
errEvt := core.Event{Type: core.EventText, Content: errMsg}
|
|
select {
|
|
case s.events <- errEvt:
|
|
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]) + "..."
|
|
}
|