mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-07 15:12:21 +08:00
Adds optional 'agent' config option under [projects.agent.options] for opencode. When set, the value is passed as --agent to every 'opencode run' invocation, enabling plugin-defined agents (e.g. oh-my-openagent's 'Sisyphus - Ultraworker') to work correctly without falling back to the default agent. Co-authored-by: Cursor <cursoragent@cursor.com>
247 lines
7.3 KiB
Go
247 lines
7.3 KiB
Go
package opencode
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"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", "/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)
|
|
}
|
|
}
|
|
|
|
// verify Agent implements core.Agent
|
|
var _ core.Agent = (*Agent)(nil)
|