Files
cc-connect/agent/codex/session.go
T
Shawn 588b22b223 fix(agent): always close events channel on session Close timeout (#163)
Six agent session adapters (codex, cursor, gemini, iflow, opencode,
qoder) only close the events channel when wg.Wait completes within
the 8-second timeout. If the timeout fires, the channel is left open,
which can cause consumers to block indefinitely waiting for channel
closure and prevents the engine from detecting that the session has
ended.

Move close(events) after the select block so the channel is always
closed regardless of whether the timeout fires, matching the pattern
already used by piSession.
2026-03-16 06:46:04 +08:00

563 lines
14 KiB
Go

package codex
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"unicode/utf8"
"github.com/chenhg5/cc-connect/core"
)
// codexSession manages a multi-turn Codex conversation.
// First Send() uses `codex exec`, subsequent ones use `codex exec resume <threadID>`.
type codexSession struct {
workDir string
model string
effort string
mode string
extraEnv []string
events chan core.Event
threadID atomic.Value // stores string — Codex thread_id
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
alive atomic.Bool
pendingMsgs []string // buffered agent_message texts awaiting classification
}
func newCodexSession(ctx context.Context, workDir, model, effort, mode, resumeID string, extraEnv []string) (*codexSession, error) {
sessionCtx, cancel := context.WithCancel(ctx)
cs := &codexSession{
workDir: workDir,
model: model,
effort: effort,
mode: mode,
extraEnv: extraEnv,
events: make(chan core.Event, 64),
ctx: sessionCtx,
cancel: cancel,
}
cs.alive.Store(true)
if resumeID != "" {
cs.threadID.Store(resumeID)
}
return cs, nil
}
// Send launches a codex subprocess.
// If a threadID exists (from a prior turn or resume), uses `codex exec resume <id> <prompt>`.
// Otherwise uses `codex exec <prompt>` to start a new conversation.
func (cs *codexSession) Send(prompt string, images []core.ImageAttachment, files []core.FileAttachment) error {
if len(files) > 0 {
filePaths := core.SaveFilesToDisk(cs.workDir, files)
prompt = core.AppendFileRefs(prompt, filePaths)
}
if !cs.alive.Load() {
return fmt.Errorf("session is closed")
}
prompt, imagePaths, err := cs.stageImages(prompt, images)
if err != nil {
return err
}
isResume := cs.CurrentSessionID() != ""
args := cs.buildExecArgs(prompt, imagePaths)
slog.Debug("codexSession: launching", "resume", isResume, "args", core.RedactArgs(args))
cmd := exec.CommandContext(cs.ctx, "codex", args...)
cmd.Dir = cs.workDir
if len(cs.extraEnv) > 0 {
cmd.Env = core.MergeEnv(os.Environ(), cs.extraEnv)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return fmt.Errorf("codexSession: stdout pipe: %w", err)
}
var stderrBuf bytes.Buffer
cmd.Stderr = &stderrBuf
if err := cmd.Start(); err != nil {
return fmt.Errorf("codexSession: start: %w", err)
}
cs.wg.Add(1)
go cs.readLoop(cmd, stdout, &stderrBuf)
return nil
}
func (cs *codexSession) stageImages(prompt string, images []core.ImageAttachment) (string, []string, error) {
if len(images) == 0 {
return prompt, nil, nil
}
imgDir := filepath.Join(cs.workDir, ".cc-connect", "images")
if err := os.MkdirAll(imgDir, 0o755); err != nil {
return "", nil, fmt.Errorf("codexSession: create image dir: %w", err)
}
imagePaths := make([]string, 0, len(images))
for i, img := range images {
ext := codexImageExt(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("codexSession: save image: %w", err)
}
imagePaths = append(imagePaths, fpath)
}
if strings.TrimSpace(prompt) == "" {
prompt = "Please analyze the attached image(s)."
}
return prompt, imagePaths, nil
}
func (cs *codexSession) buildExecArgs(prompt string, imagePaths []string) []string {
tid := cs.CurrentSessionID()
isResume := tid != ""
var args []string
if isResume {
args = []string{"exec", "resume", "--json", "--skip-git-repo-check"}
} else {
args = []string{"exec", "--json", "--skip-git-repo-check"}
}
switch cs.mode {
case "auto-edit", "full-auto":
args = append(args, "--full-auto")
case "yolo":
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
}
if cs.model != "" {
args = append(args, "--model", cs.model)
}
if cs.effort != "" {
args = append(args, "-c", fmt.Sprintf("model_reasoning_effort=%q", cs.effort))
}
if isResume {
args = append(args, tid)
for _, imagePath := range imagePaths {
args = append(args, "--image", imagePath)
}
args = append(args, prompt)
} else {
for _, imagePath := range imagePaths {
args = append(args, "--image", imagePath)
}
args = append(args, "--cd", cs.workDir, prompt)
}
return args
}
func codexImageExt(mime string) string {
switch mime {
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".png"
}
}
func (cs *codexSession) readLoop(cmd *exec.Cmd, stdout io.ReadCloser, stderrBuf *bytes.Buffer) {
defer cs.wg.Done()
defer func() {
if err := cmd.Wait(); err != nil {
stderrMsg := strings.TrimSpace(stderrBuf.String())
if stderrMsg != "" {
slog.Error("codexSession: process failed", "error", err, "stderr", stderrMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
if tid := cs.CurrentSessionID(); tid != "" {
patchSessionSource(tid)
}
}()
if err := readJSONLines(stdout, func(line []byte) error {
lineText := string(line)
if lineText == "" {
return nil
}
slog.Debug("codexSession: raw", "line", truncate(lineText, 500))
var raw map[string]any
if err := json.Unmarshal(line, &raw); err != nil {
slog.Debug("codexSession: non-JSON line", "line", lineText)
return nil
}
cs.handleEvent(raw)
return nil
}); err != nil {
slog.Error("codexSession: read stdout error", "error", err)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", err)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
}
func readJSONLines(r io.Reader, handle func([]byte) error) error {
reader := bufio.NewReader(r)
for {
line, err := reader.ReadBytes('\n')
if errors.Is(err, io.EOF) && len(line) == 0 {
return nil
}
if err != nil && !errors.Is(err, io.EOF) {
return err
}
line = bytes.TrimRight(line, "\r\n")
if len(line) > 0 {
if err := handle(line); err != nil {
return err
}
}
if errors.Is(err, io.EOF) {
return nil
}
}
}
func (cs *codexSession) handleEvent(raw map[string]any) {
eventType, _ := raw["type"].(string)
switch eventType {
case "thread.started":
if tid, ok := raw["thread_id"].(string); ok {
cs.threadID.Store(tid)
slog.Debug("codexSession: thread started", "thread_id", tid)
}
case "turn.started":
cs.pendingMsgs = cs.pendingMsgs[:0]
slog.Debug("codexSession: turn started")
case "item.started":
cs.handleItemStarted(raw)
case "item.completed":
cs.handleItemCompleted(raw)
case "turn.completed":
cs.flushPendingAsText()
evt := core.Event{Type: core.EventResult, SessionID: cs.CurrentSessionID(), Done: true}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "turn.failed":
errMsg := ""
if errObj, ok := raw["error"].(map[string]any); ok {
errMsg, _ = errObj["message"].(string)
}
if errMsg == "" {
errMsg = "turn failed (no details)"
}
slog.Warn("codexSession: turn failed", "error", errMsg)
evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", errMsg)}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "error":
msg, _ := raw["message"].(string)
if strings.Contains(msg, "Reconnecting") || strings.Contains(msg, "Falling back") {
slog.Debug("codexSession: transient error", "message", msg)
} else {
slog.Warn("codexSession: error event", "message", msg)
}
default:
slog.Debug("codexSession: unhandled event type", "type", eventType)
}
}
// flushPendingAsThinking emits all buffered agent_messages as EventThinking.
func (cs *codexSession) flushPendingAsThinking() {
for _, text := range cs.pendingMsgs {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
cs.pendingMsgs = cs.pendingMsgs[:0]
}
// flushPendingAsText emits all buffered agent_messages as EventText (final response).
func (cs *codexSession) flushPendingAsText() {
for _, text := range cs.pendingMsgs {
evt := core.Event{Type: core.EventText, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
cs.pendingMsgs = cs.pendingMsgs[:0]
}
var codexToolNames = map[string]string{
"web_search": "WebSearch",
"file_search": "FileSearch",
"code_interpreter": "CodeInterpreter",
"computer_use": "ComputerUse",
"mcp_tool": "MCP",
}
func (cs *codexSession) handleItemStarted(raw map[string]any) {
item, ok := raw["item"].(map[string]any)
if !ok {
slog.Debug("codexSession: item.started missing item field")
return
}
itemType, _ := item["type"].(string)
slog.Debug("codexSession: item.started", "item_type", itemType)
if itemType == "agent_message" || itemType == "message" || itemType == "reasoning" {
return
}
// Any non-message item is a tool use; flush pending messages as thinking first.
cs.flushPendingAsThinking()
switch itemType {
case "command_execution":
command, _ := item["command"].(string)
evt := core.Event{Type: core.EventToolUse, ToolName: "Bash", ToolInput: command}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
case "function_call":
name, _ := item["name"].(string)
args, _ := item["arguments"].(string)
evt := core.Event{Type: core.EventToolUse, ToolName: name, ToolInput: args}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
// Other tool types (web_search etc.) have empty fields at start;
// their EventToolUse is emitted from handleItemCompleted instead.
}
func (cs *codexSession) handleItemCompleted(raw map[string]any) {
item, ok := raw["item"].(map[string]any)
if !ok {
slog.Debug("codexSession: item.completed missing item field")
return
}
itemType, _ := item["type"].(string)
slog.Debug("codexSession: item.completed", "item_type", itemType)
switch itemType {
case "reasoning":
text := extractItemText(item, "summary", "summary_text")
if text != "" {
evt := core.Event{Type: core.EventThinking, Content: text}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
}
case "agent_message", "message":
text := extractItemText(item, "content", "output_text")
if text != "" {
cs.pendingMsgs = append(cs.pendingMsgs, text)
}
case "command_execution":
command, _ := item["command"].(string)
status, _ := item["status"].(string)
output, _ := item["aggregated_output"].(string)
exitCode, _ := item["exit_code"].(float64)
slog.Debug("codexSession: command completed",
"command", truncate(command, 100),
"status", status,
"exit_code", int(exitCode),
"output_len", len(output),
)
case "function_call":
name, _ := item["name"].(string)
status, _ := item["status"].(string)
output, _ := item["output"].(string)
slog.Debug("codexSession: function_call completed",
"name", name, "status", status, "output_len", len(output),
)
case "function_call_output":
slog.Debug("codexSession: function_call_output")
case "error":
msg, _ := item["message"].(string)
if msg != "" && !strings.Contains(msg, "Falling back") {
slog.Warn("codexSession: item error", "message", msg)
}
default:
if toolName, known := codexToolNames[itemType]; known {
input := codexExtractToolInput(item)
evt := core.Event{Type: core.EventToolUse, ToolName: toolName, ToolInput: input}
select {
case cs.events <- evt:
case <-cs.ctx.Done():
return
}
} else {
slog.Debug("codexSession: unhandled item type", "item_type", itemType)
}
}
}
// codexExtractToolInput extracts a human-readable input from a Codex tool item.
// For web_search, it reads action.queries[] or falls back to the top-level query.
func codexExtractToolInput(item map[string]any) string {
if action, ok := item["action"].(map[string]any); ok {
if queries, ok := action["queries"].([]any); ok && len(queries) > 0 {
var parts []string
for _, q := range queries {
if s, ok := q.(string); ok && s != "" {
parts = append(parts, s)
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
}
if q, _ := action["query"].(string); q != "" {
return q
}
}
if q, _ := item["query"].(string); q != "" {
return q
}
if n, _ := item["name"].(string); n != "" {
return n
}
return ""
}
// RespondPermission is a no-op for Codex — permissions are handled via CLI flags.
func (cs *codexSession) RespondPermission(_ string, _ core.PermissionResult) error {
return nil
}
func (cs *codexSession) Events() <-chan core.Event {
return cs.events
}
func (cs *codexSession) CurrentSessionID() string {
v, _ := cs.threadID.Load().(string)
return v
}
func (cs *codexSession) Alive() bool {
return cs.alive.Load()
}
func (cs *codexSession) Close() error {
cs.alive.Store(false)
cs.cancel()
done := make(chan struct{})
go func() {
cs.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(8 * time.Second):
slog.Warn("codexSession: close timed out, abandoning wg.Wait")
}
close(cs.events)
return nil
}
// extractItemText extracts text from an item's array field (e.g. "summary" or "content").
// It looks for elements matching the given elementType and concatenates their "text" fields.
// Falls back to the item's top-level "text" field if the array is missing or empty.
func extractItemText(item map[string]any, arrayField, elementType string) string {
if arr, ok := item[arrayField].([]any); ok {
var parts []string
for _, elem := range arr {
m, ok := elem.(map[string]any)
if !ok {
continue
}
if elementType != "" {
if t, _ := m["type"].(string); t != elementType {
continue
}
}
if t, _ := m["text"].(string); t != "" {
parts = append(parts, t)
}
}
if len(parts) > 0 {
return strings.Join(parts, "\n")
}
}
text, _ := item["text"].(string)
return text
}
func truncate(s string, maxRunes int) string {
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes]) + "..."
}