claudetool/bash: include partial output when command times out

Co-Authored-By: sketch <hello@sketch.dev>
Change-ID: s829d2dbdfda28988k
diff --git a/test/timeout_test.go b/test/timeout_test.go
new file mode 100644
index 0000000..2330d93
--- /dev/null
+++ b/test/timeout_test.go
@@ -0,0 +1,83 @@
+package test
+
+import (
+	"context"
+	"encoding/json"
+	"testing"
+
+	"sketch.dev/claudetool"
+	"sketch.dev/llm"
+)
+
+func TestBashTimeout(t *testing.T) {
+	// Create a bash tool
+	bashTool := claudetool.NewBashTool(nil)
+
+	// Create a command that will output text and then sleep
+	cmd := `echo "Starting command..."; echo "This should appear in partial output"; sleep 5; echo "This shouldn't appear"`
+
+	// Prepare the input with a very short timeout
+	input := map[string]interface{}{
+		"command": cmd,
+		"timeout": "1s", // Very short timeout to trigger the timeout case
+	}
+
+	// Marshal the input to JSON
+	inputJSON, err := json.Marshal(input)
+	if err != nil {
+		t.Fatalf("Failed to marshal input: %v", err)
+	}
+
+	// Run the bash tool
+	ctx := context.Background()
+	result, err := bashTool.Run(ctx, inputJSON)
+
+	// Check that we got an error (due to timeout)
+	if err == nil {
+		t.Fatalf("Expected timeout error, got nil")
+	}
+
+	// Error should mention timeout
+	if !containsString(err.Error(), "timed out") {
+		t.Errorf("Error doesn't mention timeout: %v", err)
+	}
+
+	// Check that we got partial output despite the error
+	if len(result) == 0 {
+		t.Fatalf("Expected partial output, got empty result")
+	}
+
+	// Verify the error mentions that partial output is included
+	if !containsString(err.Error(), "partial output included") {
+		t.Errorf("Error should mention that partial output is included: %v", err)
+	}
+
+	// The partial output should contain the initial output but not the text after sleep
+	text := ""
+	for _, content := range result {
+		if content.Type == llm.ContentTypeText {
+			text += content.Text
+		}
+	}
+
+	if !containsString(text, "Starting command") || !containsString(text, "should appear in partial output") {
+		t.Errorf("Partial output is missing expected content: %s", text)
+	}
+
+	if containsString(text, "shouldn't appear") {
+		t.Errorf("Partial output contains unexpected content (after timeout): %s", text)
+	}
+}
+
+func containsString(s, substr string) bool {
+	return s != "" && s != "<nil>" && stringIndexOf(s, substr) >= 0
+}
+
+func stringIndexOf(s, substr string) int {
+	for i := 0; i <= len(s)-len(substr); i++ {
+		if s[i:i+len(substr)] == substr {
+			return i
+		}
+	}
+	return -1
+}