agent.go: don't panic on nil initialResp
a.processUserMessage(ctx) appears to return nil, nil under
certain circumstances, and this causes a panic in processTurn().
This change makes it log the error instead of panicing.
Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/loop/agent_test.go b/loop/agent_test.go
index b3b4ae1..61b057c 100644
--- a/loop/agent_test.go
+++ b/loop/agent_test.go
@@ -2,6 +2,7 @@
import (
"context"
+ "fmt"
"net/http"
"os"
"strings"
@@ -204,3 +205,174 @@
t.Errorf("Expected 'think' tool remaining, got %s", tools[0])
}
}
+
+// TestAgentProcessTurnWithNilResponse tests the scenario where Agent.processTurn receives
+// a nil value for initialResp from processUserMessage.
+func TestAgentProcessTurnWithNilResponse(t *testing.T) {
+ // Create a mock conversation that will return nil and error
+ mockConvo := &MockConvoInterface{
+ sendMessageFunc: func(message ant.Message) (*ant.MessageResponse, error) {
+ return nil, fmt.Errorf("test error: simulating nil response")
+ },
+ }
+
+ // Create a minimal Agent instance for testing
+ agent := &Agent{
+ convo: mockConvo,
+ inbox: make(chan string, 10),
+ outbox: make(chan AgentMessage, 10),
+ outstandingLLMCalls: make(map[string]struct{}),
+ outstandingToolCalls: make(map[string]string),
+ }
+
+ // Create a test context
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ // Push a test message to the inbox so that processUserMessage will try to process it
+ agent.inbox <- "Test message"
+
+ // Call processTurn - it should exit early without panic when initialResp is nil
+ agent.processTurn(ctx)
+
+ // Verify the error message was added to outbox
+ select {
+ case msg := <-agent.outbox:
+ if msg.Type != ErrorMessageType {
+ t.Errorf("Expected error message, got message type: %s", msg.Type)
+ }
+ if !strings.Contains(msg.Content, "simulating nil response") {
+ t.Errorf("Expected error message to contain 'simulating nil response', got: %s", msg.Content)
+ }
+ case <-time.After(time.Second):
+ t.Error("Timed out waiting for error message in outbox")
+ }
+
+ // No more messages should be in the outbox since processTurn should exit early
+ select {
+ case msg := <-agent.outbox:
+ t.Errorf("Expected no more messages in outbox, but got: %+v", msg)
+ case <-time.After(100 * time.Millisecond):
+ // This is the expected outcome - no more messages
+ }
+}
+
+// MockConvoInterface implements the ConvoInterface for testing
+type MockConvoInterface struct {
+ sendMessageFunc func(message ant.Message) (*ant.MessageResponse, error)
+ sendUserTextMessageFunc func(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
+ toolResultContentsFunc func(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
+ toolResultCancelContentsFunc func(resp *ant.MessageResponse) ([]ant.Content, error)
+ cancelToolUseFunc func(toolUseID string, cause error) error
+ cumulativeUsageFunc func() ant.CumulativeUsage
+ resetBudgetFunc func(ant.Budget)
+ overBudgetFunc func() error
+}
+
+func (m *MockConvoInterface) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
+ if m.sendMessageFunc != nil {
+ return m.sendMessageFunc(message)
+ }
+ return nil, nil
+}
+
+func (m *MockConvoInterface) SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error) {
+ if m.sendUserTextMessageFunc != nil {
+ return m.sendUserTextMessageFunc(s, otherContents...)
+ }
+ return nil, nil
+}
+
+func (m *MockConvoInterface) ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error) {
+ if m.toolResultContentsFunc != nil {
+ return m.toolResultContentsFunc(ctx, resp)
+ }
+ return nil, nil
+}
+
+func (m *MockConvoInterface) ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error) {
+ if m.toolResultCancelContentsFunc != nil {
+ return m.toolResultCancelContentsFunc(resp)
+ }
+ return nil, nil
+}
+
+func (m *MockConvoInterface) CancelToolUse(toolUseID string, cause error) error {
+ if m.cancelToolUseFunc != nil {
+ return m.cancelToolUseFunc(toolUseID, cause)
+ }
+ return nil
+}
+
+func (m *MockConvoInterface) CumulativeUsage() ant.CumulativeUsage {
+ if m.cumulativeUsageFunc != nil {
+ return m.cumulativeUsageFunc()
+ }
+ return ant.CumulativeUsage{}
+}
+
+func (m *MockConvoInterface) ResetBudget(budget ant.Budget) {
+ if m.resetBudgetFunc != nil {
+ m.resetBudgetFunc(budget)
+ }
+}
+
+func (m *MockConvoInterface) OverBudget() error {
+ if m.overBudgetFunc != nil {
+ return m.overBudgetFunc()
+ }
+ return nil
+}
+
+// TestAgentProcessTurnWithNilResponseNilError tests the scenario where Agent.processTurn receives
+// a nil value for initialResp and nil error from processUserMessage.
+// This test verifies that the implementation properly handles this edge case.
+func TestAgentProcessTurnWithNilResponseNilError(t *testing.T) {
+ // Create a mock conversation that will return nil response and nil error
+ mockConvo := &MockConvoInterface{
+ sendMessageFunc: func(message ant.Message) (*ant.MessageResponse, error) {
+ return nil, nil // This is unusual but now handled gracefully
+ },
+ }
+
+ // Create a minimal Agent instance for testing
+ agent := &Agent{
+ convo: mockConvo,
+ inbox: make(chan string, 10),
+ outbox: make(chan AgentMessage, 10),
+ outstandingLLMCalls: make(map[string]struct{}),
+ outstandingToolCalls: make(map[string]string),
+ }
+
+ // Create a test context
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+
+ // Push a test message to the inbox so that processUserMessage will try to process it
+ agent.inbox <- "Test message"
+
+ // Call processTurn - it should handle nil initialResp with a descriptive error
+ err := agent.processTurn(ctx)
+
+ // Verify we get the expected error
+ if err == nil {
+ t.Error("Expected processTurn to return an error for nil initialResp, but got nil")
+ } else if !strings.Contains(err.Error(), "unexpected nil response") {
+ t.Errorf("Expected error about nil response, got: %v", err)
+ } else {
+ t.Logf("As expected, processTurn returned error: %v", err)
+ }
+
+ // Verify an error message was sent to the outbox
+ select {
+ case msg := <-agent.outbox:
+ if msg.Type != ErrorMessageType {
+ t.Errorf("Expected error message type, got: %s", msg.Type)
+ }
+ if !strings.Contains(msg.Content, "unexpected nil response") {
+ t.Errorf("Expected error about nil response, got: %s", msg.Content)
+ }
+ case <-time.After(time.Second):
+ t.Error("Timed out waiting for error message in outbox")
+ }
+}