mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-07 07:10:46 +08:00
* 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.
339 lines
11 KiB
Go
339 lines
11 KiB
Go
package opencode
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/chenhg5/cc-connect/core"
|
|
)
|
|
|
|
// TestOpencodeSessionEntry_Unmarshal verifies that OpenCode's
|
|
// `session list --format json` output can be correctly parsed.
|
|
//
|
|
// OpenCode returns `updated` and `created` as Unix timestamps in
|
|
// milliseconds (int64), not strings. This test prevents regression
|
|
// of the unmarshal error:
|
|
//
|
|
// json: cannot unmarshal number into Go struct field opencodeSessionEntry.updated of type string
|
|
func TestOpencodeSessionEntry_Unmarshal(t *testing.T) {
|
|
jsonData := `[
|
|
{
|
|
"id": "ses_2eb11bb11ffeYwQZOj25mlmGMc",
|
|
"title": "Test Session",
|
|
"updated": 1774174646445,
|
|
"created": 1774172652782,
|
|
"projectId": "b80385ead03e8b450bdb2016d434aad318f93c16",
|
|
"directory": "/path/to/project"
|
|
}
|
|
]`
|
|
|
|
var entries []opencodeSessionEntry
|
|
if err := json.Unmarshal([]byte(jsonData), &entries); err != nil {
|
|
t.Fatalf("Failed to unmarshal OpenCode session list: %v", err)
|
|
}
|
|
|
|
if len(entries) != 1 {
|
|
t.Fatalf("Expected 1 entry, got %d", len(entries))
|
|
}
|
|
|
|
e := entries[0]
|
|
if e.ID != "ses_2eb11bb11ffeYwQZOj25mlmGMc" {
|
|
t.Errorf("ID = %q, want %q", e.ID, "ses_2eb11bb11ffeYwQZOj25mlmGMc")
|
|
}
|
|
if e.Title != "Test Session" {
|
|
t.Errorf("Title = %q, want %q", e.Title, "Test Session")
|
|
}
|
|
if e.Updated != 1774174646445 {
|
|
t.Errorf("Updated = %d, want %d", e.Updated, 1774174646445)
|
|
}
|
|
if e.Created != 1774172652782 {
|
|
t.Errorf("Created = %d, want %d", e.Created, 1774172652782)
|
|
}
|
|
}
|
|
|
|
// TestNewOpencodeSession_ContinueSessionTreatedAsFresh verifies that
|
|
// the ContinueSession sentinel (__continue__) is not passed as a literal
|
|
// session ID to the CLI. This was fixed in PR #249.
|
|
func TestNewOpencodeSession_ContinueSessionTreatedAsFresh(t *testing.T) {
|
|
s, err := newOpencodeSession(context.Background(), "echo", nil, "/tmp", "", "default", "", core.ContinueSession, nil)
|
|
if err != nil {
|
|
t.Fatalf("newOpencodeSession: %v", err)
|
|
}
|
|
defer s.Close()
|
|
|
|
if got := s.CurrentSessionID(); got != "" {
|
|
t.Errorf("ContinueSession should be treated as fresh: chatID = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestOpencodeSessionStageImages(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := &opencodeSession{workDir: dir}
|
|
|
|
prompt, imagePaths, err := s.stageImages("", []core.ImageAttachment{
|
|
{MimeType: "image/jpeg", Data: []byte{0xff, 0xd8, 0xff}},
|
|
{MimeType: "image/webp", Data: []byte("webp")},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("stageImages: %v", err)
|
|
}
|
|
if prompt != "Please analyze the attached image(s)." {
|
|
t.Fatalf("prompt = %q", prompt)
|
|
}
|
|
if len(imagePaths) != 2 {
|
|
t.Fatalf("imagePaths len = %d, want 2", len(imagePaths))
|
|
}
|
|
if filepath.Ext(imagePaths[0]) != ".jpg" {
|
|
t.Fatalf("first ext = %q, want .jpg", filepath.Ext(imagePaths[0]))
|
|
}
|
|
if filepath.Ext(imagePaths[1]) != ".webp" {
|
|
t.Fatalf("second ext = %q, want .webp", filepath.Ext(imagePaths[1]))
|
|
}
|
|
for _, path := range imagePaths {
|
|
if _, err := os.Stat(path); err != nil {
|
|
t.Fatalf("expected staged image %s: %v", path, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpencodeSessionBuildRunArgsIncludesImagesAsFiles(t *testing.T) {
|
|
s := &opencodeSession{workDir: "/repo", model: "provider/model"}
|
|
|
|
got := s.buildRunArgs("describe these images", []string{"/tmp/a.png", "/tmp/b.jpg"}, "ses_123")
|
|
want := []string{
|
|
"run", "--format", "json",
|
|
"--session", "ses_123",
|
|
"--model", "provider/model",
|
|
"--dir", "/repo",
|
|
"--thinking",
|
|
"--file", "/tmp/a.png",
|
|
"--file", "/tmp/b.jpg",
|
|
}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("args = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
// TestHandleStepStart_SessionIDFromTopLevel verifies that handleStepStart
|
|
// prefers the sessionID from the top-level JSON field when both top-level
|
|
// and part-level sessionID are present. This matches OpenCode's stdout format.
|
|
func TestHandleStepStart_SessionIDFromTopLevel(t *testing.T) {
|
|
jsonData := `{"type":"step_start","sessionID":"ses_top_level","part":{"sessionID":"ses_part_level"}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
s := &opencodeSession{}
|
|
s.handleStepStart(raw)
|
|
|
|
if got := s.CurrentSessionID(); got != "ses_top_level" {
|
|
t.Errorf("sessionID = %q, want %q (should prefer top-level)", got, "ses_top_level")
|
|
}
|
|
}
|
|
|
|
// TestHandleStepStart_SessionIDFromPart verifies that handleStepStart
|
|
// falls back to the sessionID inside part when top-level sessionID is absent.
|
|
func TestHandleStepStart_SessionIDFromPart(t *testing.T) {
|
|
jsonData := `{"type":"step_start","part":{"sessionID":"ses_part_level"}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
s := &opencodeSession{}
|
|
s.handleStepStart(raw)
|
|
|
|
if got := s.CurrentSessionID(); got != "ses_part_level" {
|
|
t.Errorf("sessionID = %q, want %q (should fallback to part)", got, "ses_part_level")
|
|
}
|
|
}
|
|
|
|
// TestHandleStepStopSendsEventResult verifies that handleStepFinish sends
|
|
// an EventResult when reason="stop", signaling turn completion to the engine.
|
|
func TestHandleStepStopSendsEventResult(t *testing.T) {
|
|
jsonData := `{"type":"step_finish","part":{"reason":"stop"}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx}
|
|
s.handleStepFinish(raw)
|
|
|
|
select {
|
|
case evt := <-s.events:
|
|
if evt.Type != core.EventResult {
|
|
t.Errorf("event type = %q, want EventResult", evt.Type)
|
|
}
|
|
if !evt.Done {
|
|
t.Errorf("event.Done = false, want true")
|
|
}
|
|
default:
|
|
t.Error("expected EventResult to be sent when reason=stop")
|
|
}
|
|
}
|
|
|
|
// TestHandleStepToolCallsNoEventResult verifies that handleStepFinish does NOT
|
|
// send EventResult when reason="tool-calls", allowing the agent to continue
|
|
// with subsequent tool execution steps.
|
|
func TestHandleStepToolCallsNoEventResult(t *testing.T) {
|
|
jsonData := `{"type":"step_finish","part":{"reason":"tool-calls"}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s := &opencodeSession{events: make(chan core.Event, 1), ctx: ctx}
|
|
s.handleStepFinish(raw)
|
|
|
|
select {
|
|
case evt := <-s.events:
|
|
t.Errorf("unexpected event sent when reason=tool-calls: %v", evt)
|
|
default:
|
|
}
|
|
}
|
|
|
|
// TestHandleStepDuplicateEventResultPrevented verifies that calling
|
|
// handleStepFinish multiple times with reason="stop" only sends one
|
|
// EventResult, preventing duplicate completion signals to the engine.
|
|
func TestHandleStepDuplicateEventResultPrevented(t *testing.T) {
|
|
jsonData := `{"type":"step_finish","part":{"reason":"stop"}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s := &opencodeSession{
|
|
events: make(chan core.Event, 2),
|
|
ctx: ctx,
|
|
resultSent: atomic.Bool{},
|
|
}
|
|
|
|
s.handleStepFinish(raw)
|
|
s.handleStepFinish(raw)
|
|
|
|
count := 0
|
|
for len(s.events) > 0 {
|
|
evt := <-s.events
|
|
if evt.Type == core.EventResult {
|
|
count++
|
|
}
|
|
}
|
|
|
|
if count != 1 {
|
|
t.Errorf("EventResult count = %d, want 1 (duplicate should be prevented)", count)
|
|
}
|
|
}
|
|
|
|
// TestHandleToolUsePermissionDeniedEmitsEventText verifies that when opencode
|
|
// rejects a tool call (status="error"), the error message is emitted as an
|
|
// EventText so the engine has something meaningful to deliver instead of
|
|
// the generic "(空响应)" / "(empty response)" placeholder.
|
|
// Reproduces the scenario in issue #178 where running bash commands in
|
|
// default mode silently produced an empty response.
|
|
func TestHandleToolUsePermissionDeniedEmitsEventText(t *testing.T) {
|
|
jsonData := `{"type":"tool_use","part":{"tool":"bash","state":{"status":"error","error":"The user rejected permission to use this specific tool call.","input":{"command":"ls","description":"List files in current directory"}}}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s := &opencodeSession{events: make(chan core.Event, 4), ctx: ctx}
|
|
s.handleToolUse(raw)
|
|
|
|
var events []core.Event
|
|
for len(s.events) > 0 {
|
|
events = append(events, <-s.events)
|
|
}
|
|
|
|
if len(events) < 2 {
|
|
t.Fatalf("expected at least 2 events (EventToolUse + EventText), got %d: %v", len(events), events)
|
|
}
|
|
if events[0].Type != core.EventToolUse {
|
|
t.Errorf("events[0].Type = %v, want EventToolUse", events[0].Type)
|
|
}
|
|
if events[1].Type != core.EventText {
|
|
t.Errorf("events[1].Type = %v, want EventText (error text so engine has content)", events[1].Type)
|
|
}
|
|
if !strings.Contains(events[1].Content, "rejected permission") {
|
|
t.Errorf("EventText.Content = %q, want it to contain the rejection reason", events[1].Content)
|
|
}
|
|
}
|
|
|
|
// TestHandleToolUseCompletedDoesNotEmitExtraText verifies that a successfully
|
|
// completed tool call does NOT emit an EventText (regression guard).
|
|
func TestHandleToolUseCompletedDoesNotEmitExtraText(t *testing.T) {
|
|
jsonData := `{"type":"tool_use","part":{"tool":"bash","state":{"status":"completed","output":"file1.txt file2.txt","input":{"command":"ls"}}}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s := &opencodeSession{events: make(chan core.Event, 4), ctx: ctx}
|
|
s.handleToolUse(raw)
|
|
|
|
var events []core.Event
|
|
for len(s.events) > 0 {
|
|
events = append(events, <-s.events)
|
|
}
|
|
|
|
for _, evt := range events {
|
|
if evt.Type == core.EventText {
|
|
t.Errorf("unexpected EventText for completed tool: %q", evt.Content)
|
|
}
|
|
}
|
|
if len(events) < 2 {
|
|
t.Errorf("expected EventToolUse + EventToolResult for completed tool, got %d events", len(events))
|
|
}
|
|
}
|
|
|
|
// TestHandleToolUseErrorNoMessageNoText verifies that a tool error with empty
|
|
// error message does NOT emit a spurious empty EventText.
|
|
func TestHandleToolUseErrorNoMessageNoText(t *testing.T) {
|
|
jsonData := `{"type":"tool_use","part":{"tool":"bash","state":{"status":"error"}}}`
|
|
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(jsonData), &raw); err != nil {
|
|
t.Fatalf("unmarshal: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
s := &opencodeSession{events: make(chan core.Event, 4), ctx: ctx}
|
|
s.handleToolUse(raw)
|
|
|
|
for len(s.events) > 0 {
|
|
evt := <-s.events
|
|
if evt.Type == core.EventText {
|
|
t.Errorf("unexpected EventText for error with no message: %q", evt.Content)
|
|
}
|
|
}
|
|
}
|
|
|
|
// verify Agent implements core.Agent
|
|
var _ core.Agent = (*Agent)(nil)
|