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 -f stream-json -q`. // Subsequent turns use `-r ` 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() var gotResult bool var nonJSONLines []string 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)) nonJSONLines = append(nonJSONLines, line) continue } if raw.Type == "result" { gotResult = true } qs.handleEvent(&raw) } scanErr := scanner.Err() if scanErr != nil { slog.Error("qoderSession: scanner error", "error", scanErr) } // Wait for process to exit. exitErr := cmd.Wait() // If we already got a result event, the turn completed normally. if gotResult { if exitErr != nil { stderrMsg := strings.TrimSpace(stderrBuf.String()) if stderrMsg != "" { slog.Warn("qoderSession: process exited with error after result", "error", exitErr, "stderr", truncStr(stderrMsg, 200)) } } return } // No result event was received — emit a fallback to prevent the engine // from hanging forever on the events channel. if len(nonJSONLines) > 0 { // qodercli produced plain text instead of stream-json; forward it // as a result so the user at least sees the response. slog.Warn("qoderSession: no result event, falling back to plain-text output", "lines", len(nonJSONLines)) text := strings.Join(nonJSONLines, "\n") evt := core.Event{Type: core.EventResult, Content: text, SessionID: qs.CurrentSessionID(), Done: true} select { case qs.events <- evt: case <-qs.ctx.Done(): } } else if exitErr != nil { // Process failed with no usable output. stderrMsg := strings.TrimSpace(stderrBuf.String()) if stderrMsg == "" { stderrMsg = exitErr.Error() } slog.Error("qoderSession: process failed with no result", "error", exitErr, "stderr", truncStr(stderrMsg, 200)) evt := core.Event{Type: core.EventError, Error: fmt.Errorf("%s", stderrMsg)} select { case qs.events <- evt: case <-qs.ctx.Done(): } } else if scanErr != nil { // Scanner error with no output. evt := core.Event{Type: core.EventError, Error: fmt.Errorf("read stdout: %w", scanErr)} select { case qs.events <- evt: case <-qs.ctx.Done(): } } else { // Process exited cleanly but produced nothing at all. slog.Warn("qoderSession: process exited with no output and no result event") evt := core.Event{Type: core.EventResult, Content: "", SessionID: qs.CurrentSessionID(), Done: true} select { case qs.events <- evt: case <-qs.ctx.Done(): } } } // ── 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"` Result string `json:"result"` // qodercli 0.2.x: final text in top-level result field } type streamMessage struct { ID string `json:"id"` Role string `json:"role"` Status string `json:"status"` StopReason string `json:"stop_reason"` 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 } // qodercli <0.2: uses Status="finished" to indicate final message // qodercli 0.2.x: Status is empty/null, uses StopReason="end_turn"/"tool_use" isFinished := ev.Message.Status == "finished" || ev.Message.StopReason == "end_turn" || ev.Message.StopReason == "tool_use" if !isFinished { 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 // qodercli <0.2: result text is in message.content[].text 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 } } } } // qodercli 0.2.x: result text is in top-level "result" field if finalText == "" && ev.Result != "" { finalText = ev.Result } 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: close(qs.events) case <-time.After(8 * time.Second): slog.Warn("qoderSession: close timed out, abandoning wg.Wait") } 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]) + "..." }