opencode-cursor-agent/internal/bridge/bridge_test.go

310 lines
7.8 KiB
Go

package bridge
import (
"context"
"encoding/json"
"io"
"log/slog"
"os/exec"
"strings"
"testing"
"time"
)
func TestNewBridge(t *testing.T) {
b := NewCLIBridge("/usr/bin/agent", false, 4, 30*time.Second)
if b == nil {
t.Fatal("NewCLIBridge returned nil")
}
if b.cursorPath != "/usr/bin/agent" {
t.Errorf("cursorPath = %q, want %q", b.cursorPath, "/usr/bin/agent")
}
if cap(b.semaphore) != 4 {
t.Errorf("semaphore capacity = %d, want 4", cap(b.semaphore))
}
if b.timeout != 30*time.Second {
t.Errorf("timeout = %v, want 30s", b.timeout)
}
}
func TestNewBridge_DefaultConcurrency(t *testing.T) {
b := NewCLIBridge("agent", false, 0, 10*time.Second)
if cap(b.semaphore) != 1 {
t.Errorf("semaphore capacity = %d, want 1 (default)", cap(b.semaphore))
}
}
func TestNewBridge_NegativeConcurrency(t *testing.T) {
b := NewCLIBridge("agent", false, -5, 10*time.Second)
if cap(b.semaphore) != 1 {
t.Errorf("semaphore capacity = %d, want 1 (default for negative)", cap(b.semaphore))
}
}
func TestNewBridge_UsesACPWhenRequested(t *testing.T) {
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
b := NewBridge("agent", logger, true, false, 2, 10*time.Second)
if _, ok := b.(*ACPBridge); !ok {
t.Fatalf("expected ACPBridge, got %T", b)
}
}
func TestBuildACPCommandArgs_NoModel(t *testing.T) {
got := buildACPCommandArgs("/tmp/workspace", "auto")
want := []string{"--workspace", "/tmp/workspace", "acp"}
if len(got) != len(want) {
t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got)
}
}
}
func TestBuildACPCommandArgs_WithModel(t *testing.T) {
got := buildACPCommandArgs("/tmp/workspace", "sonnet-4.6")
want := []string{"--workspace", "/tmp/workspace", "--model", "sonnet-4.6", "acp"}
if len(got) != len(want) {
t.Fatalf("len(args) = %d, want %d (%v)", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], want[i], got)
}
}
}
func TestBuildCLICommandArgs_UsesAskMode(t *testing.T) {
got := buildCLICommandArgs("hello", "auto", "/tmp/workspace", true, false)
wantPrefix := []string{
"--print",
"--mode", "ask",
"--workspace", "/tmp/workspace",
"--model", "auto",
"--stream-partial-output", "--output-format", "stream-json",
}
if len(got) != len(wantPrefix)+1 {
t.Fatalf("unexpected arg length: %v", got)
}
for i := range wantPrefix {
if got[i] != wantPrefix[i] {
t.Fatalf("args[%d] = %q, want %q (all=%v)", i, got[i], wantPrefix[i], got)
}
}
if got[len(got)-1] != "hello" {
t.Fatalf("last arg = %q, want prompt", got[len(got)-1])
}
}
func TestBuildCLICommandArgs_ChatOnlyAddsTrust(t *testing.T) {
got := buildCLICommandArgs("hi", "", "/tmp/ws", false, true)
found := false
for _, a := range got {
if a == "--trust" {
found = true
break
}
}
if !found {
t.Fatalf("expected --trust when chatOnly=true, got args: %v", got)
}
}
// mockCmdHelper builds a bridge that executes a fake command for channel logic testing.
func mockCmdBridge(t *testing.T) *CLIBridge {
t.Helper()
// Use "echo" as a mock command that outputs valid JSON lines
// We'll override Execute logic by using a custom cursorPath that is "echo"
return NewCLIBridge("echo", false, 2, 5*time.Second)
}
func TestExecute_ContextCancelled(t *testing.T) {
b := NewCLIBridge("/bin/sleep", false, 1, 30*time.Second)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
outputChan, errChan := b.Execute(ctx, "test prompt", "gpt-4", "")
// Should receive error due to cancelled context
select {
case err := <-errChan:
if err == nil {
t.Error("expected error from cancelled context, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for error from cancelled context")
}
// outputChan should be closed
select {
case _, ok := <-outputChan:
if ok {
t.Error("expected outputChan to be closed")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for outputChan to close")
}
}
func TestExecute_SemaphoreBlocking(t *testing.T) {
b := NewCLIBridge("/bin/sleep", false, 1, 30*time.Second)
// Fill the semaphore
b.semaphore <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
_, errChan := b.Execute(ctx, "test", "model", "")
// Should get error because semaphore is full and context times out
select {
case err := <-errChan:
if err == nil {
t.Error("expected error, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for semaphore blocking error")
}
// Release the semaphore
<-b.semaphore
}
func TestExecute_InvalidCommand(t *testing.T) {
b := NewCLIBridge("/nonexistent/command", false, 1, 5*time.Second)
ctx := context.Background()
outputChan, errChan := b.Execute(ctx, "test", "model", "")
var outputs []string
for line := range outputChan {
outputs = append(outputs, line)
}
// Should have error from starting invalid command
select {
case err := <-errChan:
if err == nil {
t.Error("expected error for invalid command, got nil")
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for error")
}
}
func TestExecute_ValidJSONOutput(t *testing.T) {
// Use "printf" to simulate JSON line output
b := NewCLIBridge("printf", false, 2, 5*time.Second)
ctx := context.Background()
// printf with JSON lines
outputChan, errChan := b.Execute(ctx, `{"type":"assistant","message":{"content":[{"text":"hello"}]}}\n{"type":"result"}`, "model", "")
var outputs []string
for line := range outputChan {
outputs = append(outputs, line)
}
// Check errChan for any errors
select {
case err := <-errChan:
if err != nil {
t.Logf("error (may be expected): %v", err)
}
default:
}
if len(outputs) == 0 {
t.Log("no outputs received (printf may not handle newlines as expected)")
} else {
t.Logf("received %d output lines", len(outputs))
}
}
func TestHandleACPNotification_ForwardsAgentMessageChunk(t *testing.T) {
w := &acpWorker{}
var got strings.Builder
w.setActiveSinkLocked(func(text string) {
got.WriteString(text)
})
params, err := json.Marshal(map[string]interface{}{
"sessionId": "s1",
"update": map[string]interface{}{
"sessionUpdate": "agent_message_chunk",
"content": map[string]interface{}{
"type": "text",
"text": "嗨",
},
},
})
if err != nil {
t.Fatalf("marshal params: %v", err)
}
handled := w.handleACPNotification(acpMessage{
Method: "session/update",
Params: params,
})
if !handled {
t.Fatal("expected session/update to be handled")
}
if got.String() != "嗨" {
t.Fatalf("sink text = %q, want %q", got.String(), "嗨")
}
}
func TestHandleACPNotification_IgnoresNonAssistantContentUpdate(t *testing.T) {
w := &acpWorker{}
var got strings.Builder
w.setActiveSinkLocked(func(text string) {
got.WriteString(text)
})
params, err := json.Marshal(map[string]interface{}{
"sessionId": "s1",
"update": map[string]interface{}{
"sessionUpdate": "planner_thought_chunk",
"content": map[string]interface{}{
"type": "text",
"text": "Handling user greetings",
},
},
})
if err != nil {
t.Fatalf("marshal params: %v", err)
}
handled := w.handleACPNotification(acpMessage{
Method: "session/update",
Params: params,
})
if !handled {
t.Fatal("expected session/update to be handled")
}
if got.String() != "" {
t.Fatalf("sink text = %q, want empty", got.String())
}
}
func TestReadLoop_DoesNotPanicWhenReaderDoneIsNil(t *testing.T) {
w := &acpWorker{
pending: make(map[int]chan acpResponse),
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("readLoop should not panic when readerDone is nil, got %v", r)
}
}()
w.readLoop(io.NopCloser(strings.NewReader("")))
}
// Ensure exec is used (imported but may appear unused without integration tests)
var _ = exec.Command