Files
cc-connect/agent/qoder/session.go
T
quabugandClaude Opus 4.6 71a2f9b284 fix: ignore ContinueSession sentinel in non-Claude agents
* fix: ignore ContinueSession sentinel in non-Claude agents

The ContinueSession ("__continue__") sentinel was introduced in df5cc64
for the --continue bridge, but only the claudecode agent was updated to
handle it. All other agents (pi, gemini, codex, cursor, qoder, opencode,
iflow) stored the literal "__continue__" as their session/resume ID,
causing CLI failures like `No session found matching '__continue__'` and
persistent "(empty response)" replies.

The fix adds a `resumeID != core.ContinueSession` guard to each agent's
session constructor so the sentinel is treated as "" (fresh session).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(codex): remove --cd from exec resume args

The codex CLI's `exec resume` subcommand does not support --cd, causing
"unexpected argument '--cd'" errors on every resumed session. The process
working directory is already set via cmd.Dir, so --cd is unnecessary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 10:21:11 +08:00

327 lines
7.6 KiB
Go

package qoder
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// qoderSession manages a multi-turn Qoder conversation.
// Each Send() spawns `qodercli -p <prompt> -f stream-json -q`.
// Subsequent turns use `-r <sessionID>` to resume the conversation.
type qoderSession struct {
workDir string
model string
mode string
extraEnv []string
events chan core.Event
sessionID atomic.Value // stores string
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
}
func newQoderSession(ctx context.Context, workDir, model, mode, resumeID string, extraEnv []string) (*qoderSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
qs := &qoderSession{
workDir: workDir,
model: model,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
qs.alive.Store(true)
if resumeID != "" && resumeID != core.ContinueSession {
qs.sessionID.Store(resumeID)
}
return qs, nil
}
func (qs *qoderSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if len(images) > 0 {
slog.Warn("qoderSession: images not supported, ignoring")
}
if len(files) > 0 {
filePaths := core.SaveFilesToDisk(qs.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
}
if !qs.alive.Load() {
return fmt.Errorf("session is closed")
}
args := []string{"-p", prompt, "-f", "stream-json", "-q", "-w", qs.workDir}
sid := qs.CurrentSessionID()
if sid != "" {
args = append(args, "-r", sid)
}
if qs.mode == "yolo" {
args = append(args, "--dangerously-skip-permissions")
}
if qs.model != "" {
args = append(args, "--model", qs.model)
}
slog.Debug("qoderSession: launching", "resume", sid != "", "args_len", len(args))
cmd := exec.CommandContext(qs.ctx, "qodercli", args...)
cmd.Dir = qs.workDir
if len(qs.extraEnv) > 0 {
cmd.Env = core.MergeEnv(os.Environ(), qs.extraEnv)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("qoderSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("qoderSession: start: %w", err)
}
qs.wg.Add(1)
go qs.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (qs *qoderSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer qs.wg.Done()
defer func() {
if err := cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("qoderSession: process failed", "error", err, "stderr", truncStr(stderrMsg, 200))
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case qs.events <- evt:
case <-qs.ctx.Done():
return
}
}
}
}()
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 streamEvent
if err := json.Unmarshal([]byte(line), &raw); err != nil {
slog.Debug("qoderSession: non-JSON line", "line", truncStr(line, 100))
continue
}
qs.handleEvent(&raw)
}
if err := scanner.Err(); err != nil {
slog.Error("qoderSession: scanner error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case qs.events <- evt:
case <-qs.ctx.Done():
return
}
}
}
// ── stream-json event structures ─────────────────────────────
type streamEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
SessionID string `json:"session_id"`
Done bool `json:"done"`
Message *streamMessage `json:"message"`
}
type streamMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Status string `json:"status"`
Content json.RawMessage `json:"content"`
}
type contentItem struct {
Type string `json:"type"`
Text string `json:"text"`
Name string `json:"name"`
Input string `json:"input"`
Reason string `json:"reason"`
Content string `json:"content"`
Finished bool `json:"finished"`
}
// ── event handling ───────────────────────────────────────────
func (qs *qoderSession) handleEvent(ev *streamEvent) {
if ev.SessionID != "" {
qs.sessionID.Store(ev.SessionID)
}
switch ev.Type {
case "system":
slog.Debug("qoderSession: init", "session_id", ev.SessionID)
case "assistant":
qs.handleAssistant(ev)
case "result":
qs.handleResult(ev)
}
}
func (qs *qoderSession) handleAssistant(ev *streamEvent) {
if ev.Message == nil {
return
}
// Only process "finished" status to avoid duplicates from "tool_calling" status
if ev.Message.Status != "finished" {
return
}
var items []contentItem
if err := json.Unmarshal(ev.Message.Content, &items); err != nil {
return
}
for _, item := range items {
switch item.Type {
case "text":
if item.Text != "" {
evt := core.Event{Type: core.EventText, Content: item.Text}
select {
case qs.events <- evt:
case <-qs.ctx.Done():
return
}
}
case "function":
inputPreview := extractToolPreview(item.Input)
evt := core.Event{Type: core.EventToolUse, ToolName: item.Name, ToolInput: inputPreview}
select {
case qs.events <- evt:
case <-qs.ctx.Done():
return
}
}
}
}
func (qs *qoderSession) handleResult(ev *streamEvent) {
var finalText string
if ev.Message != nil {
var items []contentItem
if err := json.Unmarshal(ev.Message.Content, &items); err == nil {
for _, item := range items {
if item.Type == "text" && item.Text != "" {
finalText = item.Text
}
}
}
}
evt := core.Event{Type: core.EventResult, Content: finalText, SessionID: qs.CurrentSessionID(), Done: true}
select {
case qs.events <- evt:
case <-qs.ctx.Done():
return
}
}
func (qs *qoderSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (qs *qoderSession) Events() <-chan core.Event {
return qs.events
}
func (qs *qoderSession) CurrentSessionID() string {
v, _ := qs.sessionID.Load().(string)
return v
}
func (qs *qoderSession) Alive() bool {
return qs.alive.Load()
}
func (qs *qoderSession) Close() error {
qs.alive.Store(false)
qs.cancel()
done := make(chan struct{})
go func() {
qs.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(8 * time.Second):
slog.Warn("qoderSession: close timed out, abandoning wg.Wait")
}
close(qs.events)
return nil
}
// ── helpers ──────────────────────────────────────────────────
// extractToolPreview parses the JSON input of a tool call and returns a short preview string.
func extractToolPreview(inputJSON string) string {
var m map[string]any
if err := json.Unmarshal([]byte(inputJSON), &m); err != nil {
return inputJSON
}
if cmd, ok := m["command"].(string); ok {
return cmd
}
if file, ok := m["file_path"].(string); ok {
return file
}
if pattern, ok := m["pattern"].(string); ok {
return pattern
}
if query, ok := m["query"].(string); ok {
return query
}
return inputJSON
}
func truncStr(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}