Files
chenhg5-cc-connect/agent/qoder/qoder_test.go
Han 355793eaee refactor(agent): centralize cmd/env option parsing into core with unified cmd field (#1297)
* refactor: centralize cmd/env option parsing into core

- Add core.ParseCmdOpts() to unify cmd/cli_path/command option
  parsing across all agents, with deprecation warnings for old keys
- Add core.ParseConfigEnv() to parse [projects.agent.options.env]
  from config into []string KEY=VALUE format
- Rename cliBin/command struct fields to cmd consistently
- Add cliExtraArgs support so cmd="binary arg1 arg2" works
- Add configEnv field for static env that persists across SetSessionEnv
- Fix env merge order: configEnv < providerEnv < sessionEnv (was
  inconsistent in copilot and opencode agents)
- Update all tests for renamed fields

* fix(claudecode): add backward compat for deprecated cli_args_flag

Users with cli_args_flag in their config.toml will see a deprecation
warning directing them to the new cmd_args_flag key.

* docs(config): update config examples to use cmd instead of deprecated keys

- config.example.toml: cli_path → cmd, command(S) → S(6 occurrences)
- claudecode/claudecode.go: comment cli_path → cmd
- copilot/copilot_test.go: comment cliBin/cli_path → cmd

* test(core): add direct unit tests for ParseCmdOpts and ParseConfigEnv

Address review feedback on PR #1297 (P2). The two helper functions in
core/cmdopts.go are depended on by 13 agent packages; previously their
behavior was only covered indirectly via agent-level New() tests. This
commit adds direct unit tests covering:

- ParseCmdOpts: four-tier priority (cmd / cli_path / command / default),
  empty/whitespace boundaries, tokenization of extra args, no-warning
  for canonical cmd field, and capture of deprecation warnings.
- ParseConfigEnv: nil / missing key, map[string]string, map[string]any
  (TOML parser output), non-string value filtering, unsupported types,
  and input non-mutation.

Also adds structured slog attrs (deprecated_key, new_key) to all three
deprecation warnings (cli_path, command, cli_args_flag) so that future
log aggregation can scan deprecated-key usage without code changes
(review feedback P3).

Ref: PR #1297 review.

* fix(codex): use local cmd var (not stale cliBin name) in struct init

Post-rebase fixup: the struct field was renamed cliBin -> cmd in
PR #1297, and the local variable returned by core.ParseCmdOpts is
also named cmd. The rebased struct initializer still referenced
the old name 'cliBin', which no longer exists in scope. Assign
the local cmd variable to the cmd struct field.
2026-06-23 07:05:16 +08:00

472 lines
11 KiB
Go

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{cmd: "qodercli"}
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_EmitsNonFinishedText(t *testing.T) {
qs := newTestSession()
defer qs.cancel()
// Newer qodercli stream-json can emit partial text before the final frame.
ev := &streamEvent{
Type: "assistant",
Message: &streamMessage{
ID: "msg-partial",
Status: "streaming",
Content: []byte(`[{"type":"text","text":"partial"}]`),
},
}
qs.handleEvent(ev)
select {
case got := <-qs.events:
if got.Type != core.EventText || got.Content != "partial" {
t.Errorf("got type=%s content=%q, want EventText/partial", got.Type, got.Content)
}
default:
t.Error("expected a text event but channel was empty")
}
}
func TestHandleAssistant_ThinkingDoesNotBlockTextOnSameID(t *testing.T) {
qs := newTestSession()
defer qs.cancel()
thinking := &streamEvent{
Type: "assistant",
Message: &streamMessage{
ID: "msg-same-id",
Content: []byte(`[{"type":"thinking","thinking":"hidden"},{"type":"redacted_thinking","data":"secret"}]`),
},
}
text := &streamEvent{
Type: "assistant",
Message: &streamMessage{
ID: "msg-same-id",
StopReason: "end_turn",
Content: []byte(`[{"type":"text","text":"visible answer"}]`),
},
}
qs.handleEvent(thinking)
select {
case got := <-qs.events:
t.Fatalf("expected thinking frames to be skipped, got type=%s content=%q", got.Type, got.Content)
default:
}
qs.handleEvent(text)
select {
case got := <-qs.events:
if got.Type != core.EventText || got.Content != "visible answer" {
t.Errorf("got type=%s content=%q, want EventText/visible answer", got.Type, got.Content)
}
default:
t.Error("expected text after thinking frames")
}
}
func TestHandleAssistant_DeduplicatesCumulativeTextForSameMessageID(t *testing.T) {
qs := newTestSession()
defer qs.cancel()
events := []*streamEvent{
{
Type: "assistant",
Message: &streamMessage{
ID: "msg-cumulative",
Status: "streaming",
Content: []byte(`[{"type":"text","text":"Hello"}]`),
},
},
{
Type: "assistant",
Message: &streamMessage{
ID: "msg-cumulative",
Status: "streaming",
Content: []byte(`[{"type":"text","text":"Hello world"}]`),
},
},
{
Type: "assistant",
Message: &streamMessage{
ID: "msg-cumulative",
StopReason: "end_turn",
Content: []byte(`[{"type":"text","text":"Hello world"}]`),
},
},
}
for _, ev := range events {
qs.handleEvent(ev)
}
got := drainQoderEvents(qs)
if len(got) != 2 {
t.Fatalf("got %d events, want 2: %#v", len(got), got)
}
if got[0].Type != core.EventText || got[0].Content != "Hello" {
t.Errorf("event 0 = %s %q, want EventText Hello", got[0].Type, got[0].Content)
}
if got[1].Type != core.EventText || got[1].Content != " world" {
t.Errorf("event 1 = %s %q, want EventText ' world'", got[1].Type, got[1].Content)
}
}
func TestEmitAssistantTextBoundsDedupCache(t *testing.T) {
qs := newTestSession()
defer qs.cancel()
for i := 0; i < maxAssistantTextCacheEntries+1; i++ {
if !qs.emitAssistantText(fmt.Sprintf("msg-%d", i), "chunk") {
t.Fatalf("emitAssistantText(%d) returned false", i)
}
select {
case <-qs.events:
default:
t.Fatalf("expected emitted chunk for message %d", i)
}
}
qs.textMu.Lock()
defer qs.textMu.Unlock()
if got := len(qs.assistantTextByID); got != maxAssistantTextCacheEntries {
t.Fatalf("assistantTextByID len = %d, want %d", got, maxAssistantTextCacheEntries)
}
if _, ok := qs.assistantTextByID["msg-0"]; ok {
t.Fatal("oldest cached message id was not evicted")
}
if _, ok := qs.assistantTextByID[fmt.Sprintf("msg-%d", maxAssistantTextCacheEntries)]; !ok {
t.Fatal("newest cached message id was not retained")
}
}
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")
}
}
func drainQoderEvents(qs *qoderSession) []core.Event {
var events []core.Event
for {
select {
case ev := <-qs.events:
events = append(events, ev)
default:
return events
}
}
}