Files
orchestra/internal/herdr/herdr_test.go
T

291 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package herdr
import (
"bufio"
"context"
"encoding/json"
"net"
"reflect"
"regexp"
"strings"
"testing"
"time"
)
func TestHarnessStartArgs(t *testing.T) {
if got, want := harnessStartArgs("claude"), []string{}; !reflect.DeepEqual(got, want) {
t.Errorf("Claude args = %q, want %q", got, want)
}
if got := harnessStartArgs("opencode"); len(got) != 0 {
t.Errorf("OpenCode args = %q, want none", got)
}
}
func TestStartAgentPassesEmptyHarnessArgs(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
requests := make(chan Request, 2)
go func() {
for i := 0; i < 2; i++ {
conn, err := ln.Accept()
if err != nil {
return
}
var req Request
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) == nil {
requests <- req
if i == 0 {
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{}`)})
} else {
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{"type":"pane_info","pane":{"agent":"claude","agent_status":"idle"}}`)})
}
}
_ = conn.Close()
}
}()
c := &Client{
Path: ln.Addr().String(),
panes: map[string]string{"/worktree": "w1:p1"},
dial: func() (net.Conn, error) {
return net.Dial("tcp", ln.Addr().String())
},
}
if _, err := c.StartAgent(context.Background(), "", "/worktree", "", "opencode", "t1"); err != nil {
t.Fatal(err)
}
start := <-requests
if start.Method != "agent.start" {
t.Fatalf("first method = %q, want agent.start", start.Method)
}
params, err := json.Marshal(start.Params)
if err != nil {
t.Fatal(err)
}
var got struct {
Args []string `json:"args"`
Kind string `json:"kind"`
Name string `json:"name"`
}
if err := json.Unmarshal(params, &got); err != nil {
t.Fatal(err)
}
if want := []string{}; !reflect.DeepEqual(got.Args, want) {
t.Errorf("agent.start args = %q, want %q", got.Args, want)
}
if got.Kind != "opencode" || got.Name != "oc-t1" {
t.Errorf("agent.start kind/name = %q/%q, want opencode/oc-t1", got.Kind, got.Name)
}
if get := <-requests; get.Method != "pane.get" {
t.Errorf("second method = %q, want pane.get", get.Method)
}
}
func TestStartAgentAttachesTwoSameHarnessSessionsWithDistinctNames(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
starts := make(chan Request, 2)
go func() {
for i := 0; i < 4; i++ {
conn, err := ln.Accept()
if err != nil {
return
}
var req Request
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) == nil {
switch req.Method {
case "agent.start":
starts <- req
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{}`)})
case "pane.get":
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{"type":"pane_info","pane":{"agent":"opencode","agent_status":"idle"}}`)})
}
}
_ = conn.Close()
}
}()
c := &Client{Path: ln.Addr().String(), panes: map[string]string{"/one": "w1:p1", "/two": "w2:p1"}, dial: func() (net.Conn, error) {
return net.Dial("tcp", ln.Addr().String())
}}
first, err := c.StartAgent(context.Background(), "", "/one", "", "opencode", "first-task")
if err != nil {
t.Fatal(err)
}
second, err := c.StartAgent(context.Background(), "", "/two", "", "opencode", "second-task")
if err != nil {
t.Fatal(err)
}
if first.AgentName == second.AgentName || first.AgentName == "" || second.AgentName == "" {
t.Fatalf("agent names must be distinct and persisted: %+v / %+v", first, second)
}
for _, want := range []string{first.AgentName, second.AgentName} {
req := <-starts
params, _ := json.Marshal(req.Params)
var got struct {
Name string `json:"name"`
}
_ = json.Unmarshal(params, &got)
if got.Name != want {
t.Fatalf("agent.start name = %q, want %q", got.Name, want)
}
}
}
func TestStartAgentRejectsSuccessWithoutAttachment(t *testing.T) {
oldWindow, oldPoll := agentAttachWindow, agentAttachPoll
agentAttachWindow, agentAttachPoll = 25*time.Millisecond, time.Millisecond
t.Cleanup(func() { agentAttachWindow, agentAttachPoll = oldWindow, oldPoll })
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
var req Request
if json.NewDecoder(bufio.NewReader(conn)).Decode(&req) != nil {
return
}
result := json.RawMessage(`{"type":"pane_info","pane":{"agent_status":"unknown"}}`)
if req.Method == "agent.start" {
result = json.RawMessage(`{}`)
}
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: result})
}()
}
}()
c := &Client{Path: ln.Addr().String(), panes: map[string]string{"/worktree": "w1:p1"}, dial: func() (net.Conn, error) {
return net.Dial("tcp", ln.Addr().String())
}}
_, err = c.StartAgent(context.Background(), "", "/worktree", "", "opencode", "silent-noop")
if err == nil || !strings.Contains(err.Error(), "no agent attached") {
t.Fatalf("StartAgent error = %v, want explicit missing attachment", err)
}
}
func TestAgentNameIsBoundedAndValid(t *testing.T) {
got := agentName("OpenCode", "TASK With spaces / and symbols !!! 0123456789")
if len(got) > 32 || !regexp.MustCompile(`^[a-z0-9_-]+$`).MatchString(got) {
t.Fatalf("invalid agent name %q", got)
}
}
func TestAgentNameLongIDsDoNotCollide(t *testing.T) {
first := agentName("claude", "task-with-a-very-long-shared-prefix-aaaaaaaa")
second := agentName("claude", "task-with-a-very-long-shared-prefix-bbbbbbbb")
if first == second {
t.Fatalf("long task IDs collided: %q", first)
}
if len(first) > 32 || len(second) > 32 {
t.Fatalf("agent name exceeds limit: %q / %q", first, second)
}
}
func TestPromptDoesNotRetryAmbiguousDelivery(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
requests := make(chan Request, 3)
go func() {
for i := 0; i < 3; i++ {
conn, err := ln.Accept()
if err != nil {
return
}
var req Request
_ = json.NewDecoder(bufio.NewReader(conn)).Decode(&req)
requests <- req
switch req.Method {
case "pane.get":
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{"pane":{"agent_status":"idle"}}`)})
case "pane.read":
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{"read":{"text":""}}`)})
case "agent.prompt":
_ = conn.Close() // simulate post-write response loss
}
_ = conn.Close()
}
}()
c := &Client{Path: ln.Addr().String(), agents: map[string]string{"w:p": "oc-task"}, dial: func() (net.Conn, error) { return net.Dial("tcp", ln.Addr().String()) }}
if err := c.Prompt(context.Background(), "w:p", "do work", time.Millisecond); err == nil {
t.Fatal("expected uncertain delivery error")
}
for i, want := range []string{"pane.get", "pane.read", "agent.prompt"} {
select {
case req := <-requests:
if req.Method != want {
t.Fatalf("request %d = %s, want %s", i, req.Method, want)
}
if req.Method == "agent.prompt" {
p, _ := json.Marshal(req.Params)
var got struct {
Target string `json:"target"`
}
_ = json.Unmarshal(p, &got)
if got.Target != "oc-task" {
t.Fatalf("prompt target = %q, want unique agent name", got.Target)
}
}
case <-time.After(time.Second):
t.Fatal("missing request")
}
}
}
func TestClaudeWorkspaceTrustPrompt(t *testing.T) {
if !claudeWorkspaceTrustPrompt("Accessing workspace:\n 1. Yes, I trust this folder") {
t.Fatal("exact Claude trust prompt was not recognized")
}
if claudeWorkspaceTrustPrompt("WARNING: Claude Code running in Bypass Permissions mode\n 2. Yes, I accept") {
t.Fatal("bypass-permissions disclaimer must never be accepted automatically")
}
}
func TestPaneAgentAttachedParsesPaneGetEnvelope(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
var req Request
if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&req); err != nil {
return
}
if req.Method != "pane.get" {
t.Errorf("method = %q, want pane.get", req.Method)
return
}
_ = json.NewEncoder(conn).Encode(Response{ID: req.ID, Result: json.RawMessage(`{"type":"pane_info","pane":{"agent":"opencode","agent_status":"idle"}}`)})
}()
c := &Client{Path: ln.Addr().String(), dial: func() (net.Conn, error) {
return net.Dial("tcp", ln.Addr().String())
}}
attached, err := c.paneAgentAttached(context.Background(), "w1:p1")
if err != nil {
t.Fatal(err)
}
if !attached {
t.Fatal("pane.get envelope with an idle agent was not recognized as attached")
}
}