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") } }