Files
chenhg5-cc-connect/agent/opencode/session.go
cg33 f4d36dbbdf fix(opencode): surface tool rejection error as text to prevent empty response (#178) (#1247)
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>
2026-06-07 21:18:25 +08:00

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]) + "..."
}