291 lines
8.7 KiB
Go
291 lines
8.7 KiB
Go
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")
|
||
}
|
||
}
|