310 lines
7.8 KiB
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
|