初始化仓库
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
package qoder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
func init() {
|
||||
core.RegisterAgent("qoder", New)
|
||||
}
|
||||
|
||||
// Agent drives Qoder CLI using `qodercli -p <prompt> -f stream-json`.
|
||||
type Agent struct {
|
||||
workDir string
|
||||
model string
|
||||
mode string // "default" | "yolo"
|
||||
sessionEnv []string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func New(opts map[string]any) (core.Agent, error) {
|
||||
workDir, _ := opts["work_dir"].(string)
|
||||
if workDir == "" {
|
||||
workDir = "."
|
||||
}
|
||||
model, _ := opts["model"].(string)
|
||||
mode, _ := opts["mode"].(string)
|
||||
mode = normalizeMode(mode)
|
||||
|
||||
if _, err := exec.LookPath("qodercli"); err != nil {
|
||||
return nil, fmt.Errorf("qoder: 'qodercli' not found in PATH, install with: curl -fsSL https://qoder.com/install | bash")
|
||||
}
|
||||
|
||||
return &Agent{
|
||||
workDir: workDir,
|
||||
model: model,
|
||||
mode: mode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "yolo", "bypass", "dangerously-skip-permissions":
|
||||
return "yolo"
|
||||
default:
|
||||
return "default"
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) Name() string { return "qoder" }
|
||||
func (a *Agent) CLIBinaryName() string { return "qodercli" }
|
||||
func (a *Agent) CLIDisplayName() string { return "Qoder" }
|
||||
|
||||
func (a *Agent) SetWorkDir(dir string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.workDir = dir
|
||||
slog.Info("qoder: work_dir changed", "work_dir", dir)
|
||||
}
|
||||
|
||||
func (a *Agent) GetWorkDir() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.workDir
|
||||
}
|
||||
|
||||
func (a *Agent) SetModel(model string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.model = model
|
||||
slog.Info("qoder: model changed", "model", model)
|
||||
}
|
||||
|
||||
func (a *Agent) GetModel() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.model
|
||||
}
|
||||
|
||||
func (a *Agent) AvailableModels(_ context.Context) []core.ModelOption {
|
||||
return []core.ModelOption{
|
||||
{Name: "auto", Desc: "Auto (recommended)"},
|
||||
{Name: "ultimate", Desc: "Ultimate (most capable)"},
|
||||
{Name: "performance", Desc: "Performance (balanced)"},
|
||||
{Name: "efficient", Desc: "Efficient (fast)"},
|
||||
{Name: "lite", Desc: "Lite (lightweight)"},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Agent) SetSessionEnv(env []string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.sessionEnv = env
|
||||
}
|
||||
|
||||
func (a *Agent) StartSession(ctx context.Context, sessionID string) (core.AgentSession, error) {
|
||||
a.mu.Lock()
|
||||
mode := a.mode
|
||||
model := a.model
|
||||
workDir := a.workDir
|
||||
extraEnv := append([]string{}, a.sessionEnv...)
|
||||
a.mu.Unlock()
|
||||
|
||||
return newQoderSession(ctx, workDir, model, mode, sessionID, extraEnv)
|
||||
}
|
||||
|
||||
func (a *Agent) ListSessions(_ context.Context) ([]core.AgentSessionInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (a *Agent) Stop() error { return nil }
|
||||
|
||||
// ── ModeSwitcher ─────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) SetMode(mode string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.mode = normalizeMode(mode)
|
||||
slog.Info("qoder: mode changed", "mode", a.mode)
|
||||
}
|
||||
|
||||
func (a *Agent) GetMode() string {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
return a.mode
|
||||
}
|
||||
|
||||
func (a *Agent) PermissionModes() []core.PermissionModeInfo {
|
||||
return []core.PermissionModeInfo{
|
||||
{Key: "default", Name: "Default", NameZh: "默认", Desc: "Standard permissions", DescZh: "标准权限模式"},
|
||||
{Key: "yolo", Name: "YOLO", NameZh: "全自动", Desc: "Skip all permission checks", DescZh: "跳过所有权限检查"},
|
||||
}
|
||||
}
|
||||
|
||||
// ── SkillProvider ────────────────────────────────────────────
|
||||
|
||||
func (a *Agent) SkillDirs() []string {
|
||||
workDir := a.GetWorkDir()
|
||||
absDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absDir = workDir
|
||||
}
|
||||
dirs := []string{filepath.Join(absDir, ".claude", "skills")}
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".claude", "skills"))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// ── ContextCompressor ────────────────────────────────────────
|
||||
|
||||
func (a *Agent) CompressCommand() string { return "/compact" }
|
||||
|
||||
// ── MemoryFileProvider ───────────────────────────────────────
|
||||
|
||||
func (a *Agent) ProjectMemoryFile() string {
|
||||
workDir := a.GetWorkDir()
|
||||
absDir, err := filepath.Abs(workDir)
|
||||
if err != nil {
|
||||
absDir = workDir
|
||||
}
|
||||
return filepath.Join(absDir, "AGENTS.md")
|
||||
}
|
||||
|
||||
func (a *Agent) GlobalMemoryFile() string {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(homeDir, ".qoder", "AGENTS.md")
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package qoder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/chenhg5/cc-connect/core"
|
||||
)
|
||||
|
||||
// TestAgent_StartSessionWorkDirRace exercises concurrent SetWorkDir + StartSession.
|
||||
// Without the fix, StartSession reads a.workDir without holding a.mu while
|
||||
// SetWorkDir writes it under the lock, which Go's -race detector flags as a
|
||||
// data race. With the fix, the field is captured inside the existing critical
|
||||
// section and no race is reported.
|
||||
//
|
||||
// newQoderSession only initialises the session struct; it does not spawn the
|
||||
// qodercli binary until Send() is called, so this test runs without requiring
|
||||
// the CLI on PATH.
|
||||
func TestAgent_StartSessionWorkDirRace(t *testing.T) {
|
||||
a := &Agent{workDir: "/initial"}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(2)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
a.SetWorkDir(fmt.Sprintf("/path-%d", i))
|
||||
}(i)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
sess, err := a.StartSession(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Errorf("StartSession: %v", err)
|
||||
return
|
||||
}
|
||||
_ = sess.Close()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestQoderSession(t *testing.T) {
|
||||
if os.Getenv("QODER_INTEGRATION") == "" {
|
||||
t.Skip("set QODER_INTEGRATION=1 to run")
|
||||
}
|
||||
|
||||
agent, err := New(map[string]any{
|
||||
"work_dir": "/tmp",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
|
||||
sess, err := agent.StartSession(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartSession: %v", err)
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
if err := sess.Send("say hello in one word", nil, nil); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
|
||||
timeout := time.After(30 * time.Second)
|
||||
var gotResult bool
|
||||
for !gotResult {
|
||||
select {
|
||||
case ev, ok := <-sess.Events():
|
||||
if !ok {
|
||||
t.Fatal("events channel closed prematurely")
|
||||
}
|
||||
switch ev.Type {
|
||||
case core.EventText:
|
||||
fmt.Printf("[TEXT] %s\n", ev.Content)
|
||||
case core.EventToolUse:
|
||||
fmt.Printf("[TOOL] %s: %s\n", ev.ToolName, ev.ToolInput)
|
||||
case core.EventResult:
|
||||
fmt.Printf("[RESULT] sid=%s content=%s\n", ev.SessionID, ev.Content)
|
||||
gotResult = true
|
||||
case core.EventError:
|
||||
t.Fatalf("[ERROR] %v", ev.Error)
|
||||
default:
|
||||
fmt.Printf("[%s] %s\n", ev.Type, ev.Content)
|
||||
}
|
||||
case <-timeout:
|
||||
t.Fatal("timeout waiting for result")
|
||||
}
|
||||
}
|
||||
|
||||
sid := sess.CurrentSessionID()
|
||||
if sid == "" {
|
||||
t.Error("expected a session ID from init event")
|
||||
}
|
||||
fmt.Printf("Session ID: %s\n", sid)
|
||||
}
|
||||
|
||||
// Unit tests that don't require real CLI
|
||||
|
||||
func TestNormalizeMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"yolo", "yolo"},
|
||||
{"YOLO", "yolo"},
|
||||
{"bypass", "yolo"},
|
||||
{"dangerously-skip-permissions", "yolo"},
|
||||
{"default", "default"},
|
||||
{"", "default"},
|
||||
{"unknown", "default"},
|
||||
{" yolo ", "yolo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeMode(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("normalizeMode(%q) = %q, want %q", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_Name(t *testing.T) {
|
||||
a := &Agent{}
|
||||
if got := a.Name(); got != "qoder" {
|
||||
t.Errorf("Name() = %q, want %q", got, "qoder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_CLIBinaryName(t *testing.T) {
|
||||
a := &Agent{}
|
||||
if got := a.CLIBinaryName(); got != "qodercli" {
|
||||
t.Errorf("CLIBinaryName() = %q, want %q", got, "qodercli")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_CLIDisplayName(t *testing.T) {
|
||||
a := &Agent{}
|
||||
if got := a.CLIDisplayName(); got != "Qoder" {
|
||||
t.Errorf("CLIDisplayName() = %q, want %q", got, "Qoder")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_SetWorkDir(t *testing.T) {
|
||||
a := &Agent{}
|
||||
a.SetWorkDir("/tmp/test")
|
||||
if got := a.GetWorkDir(); got != "/tmp/test" {
|
||||
t.Errorf("GetWorkDir() = %q, want %q", got, "/tmp/test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgent_SetModel(t *testing.T) {
|
||||
a := &Agent{}
|
||||
a.SetModel("gpt-4")
|
||||
a.mu.Lock()
|
||||
got := a.model
|
||||
a.mu.Unlock()
|
||||
if got != "gpt-4" {
|
||||
t.Errorf("model = %q, want %q", got, "gpt-4")
|
||||
}
|
||||
}
|
||||
|
||||
// verify Agent implements core.Agent
|
||||
var _ core.Agent = (*Agent)(nil)
|
||||
|
||||
// ── handleEvent unit tests (old vs new qodercli format) ──
|
||||
|
||||
func newTestSession() *qoderSession {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
qs := &qoderSession{
|
||||
events: make(chan core.Event, 64),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
qs.alive.Store(true)
|
||||
return qs
|
||||
}
|
||||
|
||||
func TestHandleAssistant_OldFormat(t *testing.T) {
|
||||
qs := newTestSession()
|
||||
defer qs.cancel()
|
||||
|
||||
ev := &streamEvent{
|
||||
Type: "assistant",
|
||||
SessionID: "old-session-1",
|
||||
Message: &streamMessage{
|
||||
Status: "finished",
|
||||
Content: []byte(`[{"type":"text","text":"hello old"}]`),
|
||||
},
|
||||
}
|
||||
qs.handleEvent(ev)
|
||||
|
||||
select {
|
||||
case got := <-qs.events:
|
||||
if got.Type != core.EventText || got.Content != "hello old" {
|
||||
t.Errorf("got type=%s content=%q, want EventText/hello old", got.Type, got.Content)
|
||||
}
|
||||
default:
|
||||
t.Error("expected a text event but channel was empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAssistant_NewFormat(t *testing.T) {
|
||||
qs := newTestSession()
|
||||
defer qs.cancel()
|
||||
|
||||
ev := &streamEvent{
|
||||
Type: "assistant",
|
||||
SessionID: "new-session-1",
|
||||
Message: &streamMessage{
|
||||
StopReason: "end_turn",
|
||||
Content: []byte(`[{"type":"text","text":"hello new"}]`),
|
||||
},
|
||||
}
|
||||
qs.handleEvent(ev)
|
||||
|
||||
select {
|
||||
case got := <-qs.events:
|
||||
if got.Type != core.EventText || got.Content != "hello new" {
|
||||
t.Errorf("got type=%s content=%q, want EventText/hello new", got.Type, got.Content)
|
||||
}
|
||||
default:
|
||||
t.Error("expected a text event but channel was empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAssistant_ToolUseStopReason(t *testing.T) {
|
||||
qs := newTestSession()
|
||||
defer qs.cancel()
|
||||
|
||||
ev := &streamEvent{
|
||||
Type: "assistant",
|
||||
Message: &streamMessage{
|
||||
StopReason: "tool_use",
|
||||
Content: []byte(`[{"type":"function","name":"Bash","input":"{\"command\":\"ls\"}"}]`),
|
||||
},
|
||||
}
|
||||
qs.handleEvent(ev)
|
||||
|
||||
select {
|
||||
case got := <-qs.events:
|
||||
if got.Type != core.EventToolUse || got.ToolName != "Bash" {
|
||||
t.Errorf("got type=%s tool=%s, want EventToolUse/Bash", got.Type, got.ToolName)
|
||||
}
|
||||
default:
|
||||
t.Error("expected a tool_use event but channel was empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAssistant_SkipsNonFinished(t *testing.T) {
|
||||
qs := newTestSession()
|
||||
defer qs.cancel()
|
||||
|
||||
// Neither status="finished" nor stop_reason set — should be skipped
|
||||
ev := &streamEvent{
|
||||
Type: "assistant",
|
||||
Message: &streamMessage{
|
||||
Status: "tool_calling",
|
||||
Content: []byte(`[{"type":"text","text":"should be skipped"}]`),
|
||||
},
|
||||
}
|
||||
qs.handleEvent(ev)
|
||||
|
||||
select {
|
||||
case got := <-qs.events:
|
||||
t.Errorf("expected no event, got type=%s content=%q", got.Type, got.Content)
|
||||
default:
|
||||
// ok
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResult_OldFormat(t *testing.T) {
|
||||
qs := newTestSession()
|
||||
defer qs.cancel()
|
||||
|
||||
ev := &streamEvent{
|
||||
Type: "result",
|
||||
SessionID: "old-session-1",
|
||||
Message: &streamMessage{
|
||||
Content: []byte(`[{"type":"text","text":"result old"}]`),
|
||||
},
|
||||
}
|
||||
qs.handleEvent(ev)
|
||||
|
||||
select {
|
||||
case got := <-qs.events:
|
||||
if got.Type != core.EventResult || got.Content != "result old" {
|
||||
t.Errorf("got type=%s content=%q, want EventResult/result old", got.Type, got.Content)
|
||||
}
|
||||
default:
|
||||
t.Error("expected a result event but channel was empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResult_NewFormat(t *testing.T) {
|
||||
qs := newTestSession()
|
||||
defer qs.cancel()
|
||||
|
||||
// 0.2.x: message is nil, result text in top-level field
|
||||
ev := &streamEvent{
|
||||
Type: "result",
|
||||
SessionID: "new-session-1",
|
||||
Result: "result new",
|
||||
}
|
||||
qs.handleEvent(ev)
|
||||
|
||||
select {
|
||||
case got := <-qs.events:
|
||||
if got.Type != core.EventResult || got.Content != "result new" {
|
||||
t.Errorf("got type=%s content=%q, want EventResult/result new", got.Type, got.Content)
|
||||
}
|
||||
default:
|
||||
t.Error("expected a result event but channel was empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResult_OldFormatTakesPriority(t *testing.T) {
|
||||
qs := newTestSession()
|
||||
defer qs.cancel()
|
||||
|
||||
// If both message.content and top-level result exist, message.content wins
|
||||
ev := &streamEvent{
|
||||
Type: "result",
|
||||
Result: "fallback text",
|
||||
Message: &streamMessage{
|
||||
Content: []byte(`[{"type":"text","text":"primary text"}]`),
|
||||
},
|
||||
}
|
||||
qs.handleEvent(ev)
|
||||
|
||||
select {
|
||||
case got := <-qs.events:
|
||||
if got.Content != "primary text" {
|
||||
t.Errorf("got content=%q, want primary text", got.Content)
|
||||
}
|
||||
default:
|
||||
t.Error("expected a result event but channel was empty")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
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()
|
||||
|
||||
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]) + "..."
|
||||
}
|
||||
Reference in New Issue
Block a user