- Fix data race on output strings.Builder in codex backend by adding mutex and waiting for reader goroutine before reading final output - Fix data race on onTurnDone by initializing it before reader starts - Fix bug where notificationProtocol zero value "" never matched "unknown", silently dropping all raw v2 notifications from codex - Add round-robin polling to prevent runtime starvation in poll loop - Log errors in claude handleControlRequest instead of silently dropping - Add 35 tests for pkg/agent covering claude parsing, codex JSON-RPC, protocol detection, event handling, and helper functions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
229 lines
5 KiB
Go
229 lines
5 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"log"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestClaudeHandleAssistantText(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
|
ch := make(chan Message, 10)
|
|
var output strings.Builder
|
|
|
|
msg := claudeSDKMessage{
|
|
Type: "assistant",
|
|
Message: mustMarshal(t, claudeMessageContent{
|
|
Role: "assistant",
|
|
Content: []claudeContentBlock{
|
|
{Type: "text", Text: "Hello world"},
|
|
},
|
|
}),
|
|
}
|
|
|
|
b.handleAssistant(msg, ch, &output)
|
|
|
|
if output.String() != "Hello world" {
|
|
t.Fatalf("expected output 'Hello world', got %q", output.String())
|
|
}
|
|
select {
|
|
case m := <-ch:
|
|
if m.Type != MessageText || m.Content != "Hello world" {
|
|
t.Fatalf("unexpected message: %+v", m)
|
|
}
|
|
default:
|
|
t.Fatal("expected message on channel")
|
|
}
|
|
}
|
|
|
|
func TestClaudeHandleAssistantToolUse(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
|
ch := make(chan Message, 10)
|
|
var output strings.Builder
|
|
|
|
msg := claudeSDKMessage{
|
|
Type: "assistant",
|
|
Message: mustMarshal(t, claudeMessageContent{
|
|
Role: "assistant",
|
|
Content: []claudeContentBlock{
|
|
{
|
|
Type: "tool_use",
|
|
ID: "call-1",
|
|
Name: "Read",
|
|
Input: mustMarshal(t, map[string]any{"path": "/tmp/foo"}),
|
|
},
|
|
},
|
|
}),
|
|
}
|
|
|
|
b.handleAssistant(msg, ch, &output)
|
|
|
|
if output.String() != "" {
|
|
t.Fatalf("tool_use should not add to output, got %q", output.String())
|
|
}
|
|
select {
|
|
case m := <-ch:
|
|
if m.Type != MessageToolUse || m.Tool != "Read" || m.CallID != "call-1" {
|
|
t.Fatalf("unexpected message: %+v", m)
|
|
}
|
|
if m.Input["path"] != "/tmp/foo" {
|
|
t.Fatalf("expected input path /tmp/foo, got %v", m.Input["path"])
|
|
}
|
|
default:
|
|
t.Fatal("expected message on channel")
|
|
}
|
|
}
|
|
|
|
func TestClaudeHandleUserToolResult(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
|
ch := make(chan Message, 10)
|
|
|
|
msg := claudeSDKMessage{
|
|
Type: "user",
|
|
Message: mustMarshal(t, claudeMessageContent{
|
|
Role: "user",
|
|
Content: []claudeContentBlock{
|
|
{
|
|
Type: "tool_result",
|
|
ToolUseID: "call-1",
|
|
Content: mustMarshal(t, "file contents here"),
|
|
},
|
|
},
|
|
}),
|
|
}
|
|
|
|
b.handleUser(msg, ch)
|
|
|
|
select {
|
|
case m := <-ch:
|
|
if m.Type != MessageToolResult || m.CallID != "call-1" {
|
|
t.Fatalf("unexpected message: %+v", m)
|
|
}
|
|
default:
|
|
t.Fatal("expected message on channel")
|
|
}
|
|
}
|
|
|
|
func TestClaudeHandleControlRequestAutoApproves(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var buf bytes.Buffer
|
|
b := &claudeBackend{cfg: Config{Logger: log.New(&buf, "", 0)}}
|
|
|
|
var written bytes.Buffer
|
|
|
|
msg := claudeSDKMessage{
|
|
Type: "control_request",
|
|
RequestID: "req-42",
|
|
Request: mustMarshal(t, claudeControlRequestPayload{
|
|
Subtype: "tool_use",
|
|
ToolName: "Bash",
|
|
Input: mustMarshal(t, map[string]any{"command": "ls"}),
|
|
}),
|
|
}
|
|
|
|
b.handleControlRequest(msg, &written)
|
|
|
|
var resp map[string]any
|
|
if err := json.Unmarshal(bytes.TrimSpace(written.Bytes()), &resp); err != nil {
|
|
t.Fatalf("unmarshal response: %v", err)
|
|
}
|
|
|
|
if resp["type"] != "control_response" {
|
|
t.Fatalf("expected type control_response, got %v", resp["type"])
|
|
}
|
|
respInner := resp["response"].(map[string]any)
|
|
if respInner["request_id"] != "req-42" {
|
|
t.Fatalf("expected request_id req-42, got %v", respInner["request_id"])
|
|
}
|
|
innerResp := respInner["response"].(map[string]any)
|
|
if innerResp["behavior"] != "allow" {
|
|
t.Fatalf("expected behavior allow, got %v", innerResp["behavior"])
|
|
}
|
|
}
|
|
|
|
func TestClaudeHandleAssistantInvalidJSON(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
b := &claudeBackend{cfg: Config{Logger: log.Default()}}
|
|
ch := make(chan Message, 10)
|
|
var output strings.Builder
|
|
|
|
msg := claudeSDKMessage{
|
|
Type: "assistant",
|
|
Message: json.RawMessage(`invalid json`),
|
|
}
|
|
|
|
// Should not panic
|
|
b.handleAssistant(msg, ch, &output)
|
|
|
|
if output.String() != "" {
|
|
t.Fatalf("expected empty output for invalid JSON, got %q", output.String())
|
|
}
|
|
select {
|
|
case m := <-ch:
|
|
t.Fatalf("expected no message, got %+v", m)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestTrySendDropsWhenFull(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ch := make(chan Message, 1)
|
|
// Fill the channel
|
|
trySend(ch, Message{Type: MessageText, Content: "first"})
|
|
// This should not block
|
|
trySend(ch, Message{Type: MessageText, Content: "second"})
|
|
|
|
m := <-ch
|
|
if m.Content != "first" {
|
|
t.Fatalf("expected 'first', got %q", m.Content)
|
|
}
|
|
select {
|
|
case m := <-ch:
|
|
t.Fatalf("expected empty channel, got %+v", m)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestBuildEnvAppendsExtras(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := buildEnv(map[string]string{"FOO": "bar", "BAZ": "qux"})
|
|
found := 0
|
|
for _, e := range env {
|
|
if e == "FOO=bar" || e == "BAZ=qux" {
|
|
found++
|
|
}
|
|
}
|
|
if found != 2 {
|
|
t.Fatalf("expected 2 extra env vars, found %d", found)
|
|
}
|
|
}
|
|
|
|
func TestBuildEnvNilExtras(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
env := buildEnv(nil)
|
|
if len(env) == 0 {
|
|
t.Fatal("expected at least system env vars")
|
|
}
|
|
}
|
|
|
|
func mustMarshal(t *testing.T, v any) json.RawMessage {
|
|
t.Helper()
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal: %v", err)
|
|
}
|
|
return data
|
|
}
|