diff --git a/agent/test/README.md b/agent/test/README.md index d23d7efc..bdbd4e14 100644 --- a/agent/test/README.md +++ b/agent/test/README.md @@ -272,6 +272,111 @@ The `options.metadata` field is passed to agent hooks. For example, a Create Hoo | `Message` | Single message | `{"role": "user", "content": "..."}` | | `[]Message` | Conversation history | `[{"role": "user", ...}, {"role": "assistant", ...}]` | +### File Attachments + +Test inputs support file attachments (images, audio, documents) using the `file://` protocol. Files are loaded and converted to appropriate formats for the LLM. + +**Supported file types:** + +| Type | Extensions | Format | +| ------ | ---------------------------------------------------------------------- | ------------------------------ | +| Image | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.bmp` | Base64 data URL in `image_url` | +| Audio | `.wav`, `.mp3`, `.flac`, `.ogg`, `.m4a` | Base64 in `input_audio` | +| Doc | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.txt`, `.csv`, `.json` | Base64 data URL in `file` | +| Source | `.yao`, `.ts`, `.js`, `.go`, `.py`, `.rs`, `.java`, `.sql`, `.yaml`... | Base64 data URL in `file` | + +**File path resolution:** + +- **Relative paths**: Resolved relative to the JSONL input file's directory (for file mode) or current working directory (for message mode) +- **Absolute paths**: Used as-is + +**Example with image attachment:** + +```jsonl +{ + "id": "T001", + "input": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Please analyze this invoice" + }, + { + "type": "image", + "source": "file://fixtures/invoice.jpg" + } + ] + }, + "assert": { + "type": "contains", + "value": "amount" + } +} +``` + +**Example with multiple attachments:** + +```jsonl +{ + "id": "T002", + "input": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Process these receipts" + }, + { + "type": "image", + "source": "file://fixtures/receipt1.png" + }, + { + "type": "image", + "source": "file://fixtures/receipt2.png" + }, + { + "type": "file", + "source": "file://fixtures/policy.pdf", + "name": "expense_policy.pdf" + } + ] + } +} +``` + +**Example with audio:** + +```jsonl +{ + "id": "T003", + "input": { + "role": "user", + "content": [ + { + "type": "text", + "text": "Transcribe this audio" + }, + { + "type": "audio", + "source": "file://fixtures/recording.wav" + } + ] + } +} +``` + +**Content part types:** + +| Type | Fields | Description | +| ----------- | --------------------------------------- | -------------------------------- | +| `text` | `text` | Text content | +| `image` | `source` (file://) or `url` | Image attachment | +| `image_url` | `image_url: {url, detail?}` | Direct image URL (OpenAI format) | +| `audio` | `source` (file://) or `data`, `format` | Audio attachment | +| `file` | `source` (file://) or `url`, `filename` | Document attachment | +| `data` | `data: {sources: [...]}` | Data source references | + ## Assertions Use `assert` for flexible validation. If `assert` is defined, it takes precedence over `expected`. diff --git a/agent/test/assert.go b/agent/test/assert.go index 4ac7ef8d..fc3c97e7 100644 --- a/agent/test/assert.go +++ b/agent/test/assert.go @@ -255,27 +255,32 @@ func (a *Asserter) assertJSONPath(assertion *Assertion, output interface{}) *Ass result.Actual = actual // Compare expected value with actual value - // If expected is an array, check if actual matches ANY element (IN semantics) + // First try direct comparison (handles array-to-array comparison) + if validateOutput(actual, assertion.Value) { + result.Passed = true + result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path) + return result + } + + // If expected is an array and direct comparison failed, check if actual matches ANY element (IN semantics) + // This is for cases like: expected: ["a", "b"], actual: "a" (actual is one of expected) if expectedArr, ok := assertion.Value.([]interface{}); ok { - // Check if actual is one of the expected values - for _, expectedItem := range expectedArr { - if validateOutput(actual, expectedItem) { - result.Passed = true - result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path) - return result + // Only apply IN semantics if actual is NOT an array (otherwise it was already compared above) + if _, actualIsArr := actual.([]interface{}); !actualIsArr { + for _, expectedItem := range expectedArr { + if validateOutput(actual, expectedItem) { + result.Passed = true + result.Message = fmt.Sprintf("path '%s' equals one of expected values", assertion.Path) + return result + } } } result.Passed = false - result.Message = fmt.Sprintf("path '%s': expected one of %v, got %v", assertion.Path, assertion.Value, actual) + result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual) } else { - // Direct comparison for non-array expected values - if validateOutput(actual, assertion.Value) { - result.Passed = true - result.Message = fmt.Sprintf("path '%s' equals expected value", assertion.Path) - } else { - result.Passed = false - result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual) - } + // Direct comparison already failed above + result.Passed = false + result.Message = fmt.Sprintf("path '%s': expected %v, got %v", assertion.Path, assertion.Value, actual) } return result diff --git a/agent/test/assert_test.go b/agent/test/assert_test.go new file mode 100644 index 00000000..05c9d850 --- /dev/null +++ b/agent/test/assert_test.go @@ -0,0 +1,305 @@ +package test + +import ( + "testing" +) + +func TestAsserter_JSONPath_ArrayEquality(t *testing.T) { + asserter := NewAsserter() + + tests := []struct { + name string + tc *Case + output interface{} + expected bool + errMsg string + }{ + { + name: "array equals array - same content", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "search_types", + "value": []interface{}{"db"}, + }, + }, + output: map[string]interface{}{ + "search_types": []interface{}{"db"}, + }, + expected: true, + }, + { + name: "array equals array - different content", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "search_types", + "value": []interface{}{"db"}, + }, + }, + output: map[string]interface{}{ + "search_types": []interface{}{"web"}, + }, + expected: false, + }, + { + name: "array equals array - multiple elements", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "search_types", + "value": []interface{}{"web", "db"}, + }, + }, + output: map[string]interface{}{ + "search_types": []interface{}{"web", "db"}, + }, + expected: true, + }, + { + name: "array equals array - different order", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "search_types", + "value": []interface{}{"db", "web"}, + }, + }, + output: map[string]interface{}{ + "search_types": []interface{}{"web", "db"}, + }, + expected: false, // Order matters for array equality + }, + { + name: "scalar in array - match", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "status", + "value": []interface{}{"active", "pending"}, + }, + }, + output: map[string]interface{}{ + "status": "active", + }, + expected: true, // "active" is one of ["active", "pending"] + }, + { + name: "scalar in array - no match", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "status", + "value": []interface{}{"active", "pending"}, + }, + }, + output: map[string]interface{}{ + "status": "inactive", + }, + expected: false, + }, + { + name: "simple value comparison", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "need_search", + "value": true, + }, + }, + output: map[string]interface{}{ + "need_search": true, + }, + expected: true, + }, + { + name: "nested path", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "result.count", + "value": float64(5), + }, + }, + output: map[string]interface{}{ + "result": map[string]interface{}{ + "count": float64(5), + }, + }, + expected: true, + }, + { + name: "array index access", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "json_path", + "path": "items[0].name", + "value": "first", + }, + }, + output: map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"name": "first"}, + map[string]interface{}{"name": "second"}, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + passed, errMsg := asserter.Validate(tt.tc, tt.output) + if passed != tt.expected { + t.Errorf("Expected passed=%v, got passed=%v, error: %s", tt.expected, passed, errMsg) + } + }) + } +} + +func TestAsserter_Contains(t *testing.T) { + asserter := NewAsserter() + + tests := []struct { + name string + tc *Case + output interface{} + expected bool + }{ + { + name: "string contains substring", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "contains", + "value": "hello", + }, + }, + output: "hello world", + expected: true, + }, + { + name: "string does not contain", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "contains", + "value": "goodbye", + }, + }, + output: "hello world", + expected: false, + }, + { + name: "JSON contains field", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "contains", + "value": "success", + }, + }, + output: map[string]interface{}{ + "status": "success", + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + passed, _ := asserter.Validate(tt.tc, tt.output) + if passed != tt.expected { + t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed) + } + }) + } +} + +func TestAsserter_MultipleAssertions(t *testing.T) { + asserter := NewAsserter() + + tc := &Case{ + Assert: []interface{}{ + map[string]interface{}{ + "type": "json_path", + "path": "need_search", + "value": true, + }, + map[string]interface{}{ + "type": "json_path", + "path": "search_types", + "value": []interface{}{"web"}, + }, + }, + } + + output := map[string]interface{}{ + "need_search": true, + "search_types": []interface{}{"web"}, + } + + passed, errMsg := asserter.Validate(tc, output) + if !passed { + t.Errorf("Expected all assertions to pass, got error: %s", errMsg) + } +} + +func TestAsserter_Negate(t *testing.T) { + asserter := NewAsserter() + + tc := &Case{ + Assert: map[string]interface{}{ + "type": "contains", + "value": "error", + "negate": true, + }, + } + + output := "success message" + + passed, _ := asserter.Validate(tc, output) + if !passed { + t.Error("Expected negated assertion to pass") + } +} + +func TestAsserter_Regex(t *testing.T) { + asserter := NewAsserter() + + tests := []struct { + name string + tc *Case + output interface{} + expected bool + }{ + { + name: "regex matches", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "regex", + "value": `\d{3}-\d{4}`, + }, + }, + output: "Phone: 123-4567", + expected: true, + }, + { + name: "regex does not match", + tc: &Case{ + Assert: map[string]interface{}{ + "type": "regex", + "value": `\d{3}-\d{4}`, + }, + }, + output: "No phone number here", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + passed, _ := asserter.Validate(tt.tc, tt.output) + if passed != tt.expected { + t.Errorf("Expected passed=%v, got passed=%v", tt.expected, passed) + } + }) + } +} diff --git a/agent/test/input.go b/agent/test/input.go index 32df9768..7f4201d2 100644 --- a/agent/test/input.go +++ b/agent/test/input.go @@ -1,22 +1,123 @@ package test import ( + "encoding/base64" "fmt" + "mime" + "os" + "path/filepath" + "strings" jsoniter "github.com/json-iterator/go" "github.com/yaoapp/yao/agent/context" ) +// FileProtocol is the protocol prefix for local file references +const FileProtocol = "file://" + +// SupportedImageExtensions lists supported image file extensions +var SupportedImageExtensions = map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", +} + +// SupportedAudioExtensions lists supported audio file extensions +var SupportedAudioExtensions = map[string]string{ + ".wav": "wav", + ".mp3": "mp3", + ".flac": "flac", + ".ogg": "ogg", + ".m4a": "m4a", +} + +// SupportedFileExtensions lists supported document file extensions +var SupportedFileExtensions = map[string]string{ + // Documents + ".pdf": "application/pdf", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".txt": "text/plain", + ".csv": "text/csv", + ".json": "application/json", + ".xml": "application/xml", + ".html": "text/html", + ".htm": "text/html", + ".md": "text/markdown", + + // Source code + ".yao": "application/json", // Yao DSL files (JSON-based) + ".ts": "text/typescript", // TypeScript + ".tsx": "text/typescript", // TypeScript JSX + ".js": "text/javascript", // JavaScript + ".jsx": "text/javascript", // JavaScript JSX + ".go": "text/x-go", // Go + ".py": "text/x-python", // Python + ".rs": "text/x-rust", // Rust + ".java": "text/x-java", // Java + ".c": "text/x-c", // C + ".cpp": "text/x-c++", // C++ + ".h": "text/x-c", // C header + ".hpp": "text/x-c++", // C++ header + ".rb": "text/x-ruby", // Ruby + ".php": "text/x-php", // PHP + ".sh": "text/x-shellscript", // Shell script + ".bash": "text/x-shellscript", // Bash script + ".zsh": "text/x-shellscript", // Zsh script + ".sql": "text/x-sql", // SQL + ".yaml": "text/yaml", // YAML + ".yml": "text/yaml", // YAML + ".toml": "text/x-toml", // TOML + ".ini": "text/x-ini", // INI + ".conf": "text/plain", // Config files + ".css": "text/css", // CSS + ".scss": "text/x-scss", // SCSS + ".less": "text/x-less", // LESS + ".vue": "text/x-vue", // Vue + ".svelte": "text/x-svelte", // Svelte +} + +// InputOptions configures how input is parsed +type InputOptions struct { + // BaseDir is the base directory for resolving relative file paths + // If empty, the current working directory is used + BaseDir string +} + // ParseInput converts various input formats to []context.Message // Supported formats: // - string: converted to single user message // - map (Message): single message with role and content // - []interface{} ([]Message): array of messages (conversation history) func ParseInput(input interface{}) ([]context.Message, error) { + return ParseInputWithOptions(input, nil) +} + +// ParseInputWithOptions converts various input formats to []context.Message with options +// Supported formats: +// - string: converted to single user message +// - map (Message): single message with role and content +// - []interface{} ([]Message): array of messages (conversation history) +// +// File references in content parts (type="image", "file", "audio") with "source" field +// starting with "file://" will be loaded and converted to appropriate format: +// - Images: converted to base64 data URL in image_url field +// - Audio: converted to base64 in input_audio field +// - Files: converted to base64 data URL in file field +func ParseInputWithOptions(input interface{}, opts *InputOptions) ([]context.Message, error) { if input == nil { return nil, fmt.Errorf("input is nil") } + if opts == nil { + opts = &InputOptions{} + } + switch v := input.(type) { case string: // Simple string input -> single user message @@ -29,7 +130,7 @@ func ParseInput(input interface{}) ([]context.Message, error) { case map[string]interface{}: // Single message object - msg, err := parseMessageMap(v) + msg, err := parseMessageMap(v, opts) if err != nil { return nil, fmt.Errorf("failed to parse message: %w", err) } @@ -41,7 +142,7 @@ func ParseInput(input interface{}) ([]context.Message, error) { for i, item := range v { switch m := item.(type) { case map[string]interface{}: - msg, err := parseMessageMap(m) + msg, err := parseMessageMap(m, opts) if err != nil { return nil, fmt.Errorf("failed to parse message at index %d: %w", i, err) } @@ -58,7 +159,7 @@ func ParseInput(input interface{}) ([]context.Message, error) { } // parseMessageMap converts a map to context.Message -func parseMessageMap(m map[string]interface{}) (*context.Message, error) { +func parseMessageMap(m map[string]interface{}, opts *InputOptions) (*context.Message, error) { msg := &context.Message{} // Parse role (required) @@ -71,7 +172,12 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) { // Parse content (required) if content, ok := m["content"]; ok { - msg.Content = content + // Process content to handle file:// references + processedContent, err := processContent(content, opts) + if err != nil { + return nil, fmt.Errorf("failed to process content: %w", err) + } + msg.Content = processedContent } else { return nil, fmt.Errorf("message missing 'content' field") } @@ -108,6 +214,389 @@ func parseMessageMap(m map[string]interface{}) (*context.Message, error) { return msg, nil } +// processContent processes content to handle file:// references +// Returns the processed content with files loaded and converted +func processContent(content interface{}, opts *InputOptions) (interface{}, error) { + switch v := content.(type) { + case string: + // Simple string content, no processing needed + return v, nil + + case []interface{}: + // Array of content parts + processedParts := make([]context.ContentPart, 0, len(v)) + for i, part := range v { + if partMap, ok := part.(map[string]interface{}); ok { + processedPart, err := processContentPart(partMap, opts) + if err != nil { + return nil, fmt.Errorf("failed to process content part at index %d: %w", i, err) + } + processedParts = append(processedParts, *processedPart) + } else { + return nil, fmt.Errorf("invalid content part type at index %d: expected object, got %T", i, part) + } + } + return processedParts, nil + + case map[string]interface{}: + // Single content part + processedPart, err := processContentPart(v, opts) + if err != nil { + return nil, fmt.Errorf("failed to process content part: %w", err) + } + return []context.ContentPart{*processedPart}, nil + + default: + return content, nil + } +} + +// processContentPart processes a single content part map +// Handles file:// references and converts them to appropriate format +func processContentPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) { + partType, _ := partMap["type"].(string) + + switch partType { + case "text": + text, _ := partMap["text"].(string) + return &context.ContentPart{ + Type: context.ContentText, + Text: text, + }, nil + + case "image": + return processImagePart(partMap, opts) + + case "image_url": + // Already in correct format, just parse it + return parseImageURLPart(partMap) + + case "audio", "input_audio": + return processAudioPart(partMap, opts) + + case "file": + return processFilePart(partMap, opts) + + case "data": + return parseDataPart(partMap) + + default: + // Unknown type, try to preserve as-is + return parseGenericPart(partMap) + } +} + +// processImagePart processes an image content part +// Supports: source="file://path" for local files +func processImagePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) { + source, hasSource := partMap["source"].(string) + + // Check for file:// protocol + if hasSource && strings.HasPrefix(source, FileProtocol) { + filePath := strings.TrimPrefix(source, FileProtocol) + return loadImageFile(filePath, opts) + } + + // Check for url field (already a URL or base64) + if url, ok := partMap["url"].(string); ok { + detail := context.DetailAuto + if d, ok := partMap["detail"].(string); ok { + detail = context.ImageDetailLevel(d) + } + return &context.ContentPart{ + Type: context.ContentImageURL, + ImageURL: &context.ImageURL{ + URL: url, + Detail: detail, + }, + }, nil + } + + return nil, fmt.Errorf("image part requires 'source' (file://...) or 'url' field") +} + +// parseImageURLPart parses an image_url content part +func parseImageURLPart(partMap map[string]interface{}) (*context.ContentPart, error) { + imageURL, ok := partMap["image_url"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("image_url part requires 'image_url' object") + } + + url, _ := imageURL["url"].(string) + detail := context.DetailAuto + if d, ok := imageURL["detail"].(string); ok { + detail = context.ImageDetailLevel(d) + } + + return &context.ContentPart{ + Type: context.ContentImageURL, + ImageURL: &context.ImageURL{ + URL: url, + Detail: detail, + }, + }, nil +} + +// processAudioPart processes an audio content part +// Supports: source="file://path" for local files +func processAudioPart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) { + source, hasSource := partMap["source"].(string) + + // Check for file:// protocol + if hasSource && strings.HasPrefix(source, FileProtocol) { + filePath := strings.TrimPrefix(source, FileProtocol) + return loadAudioFile(filePath, opts) + } + + // Check for data field (already base64) + if data, ok := partMap["data"].(string); ok { + format, _ := partMap["format"].(string) + return &context.ContentPart{ + Type: context.ContentInputAudio, + InputAudio: &context.InputAudio{ + Data: data, + Format: format, + }, + }, nil + } + + // Check for input_audio field + if inputAudio, ok := partMap["input_audio"].(map[string]interface{}); ok { + data, _ := inputAudio["data"].(string) + format, _ := inputAudio["format"].(string) + return &context.ContentPart{ + Type: context.ContentInputAudio, + InputAudio: &context.InputAudio{ + Data: data, + Format: format, + }, + }, nil + } + + return nil, fmt.Errorf("audio part requires 'source' (file://...) or 'data'/'input_audio' field") +} + +// processFilePart processes a file content part +// Supports: source="file://path" for local files +func processFilePart(partMap map[string]interface{}, opts *InputOptions) (*context.ContentPart, error) { + source, hasSource := partMap["source"].(string) + + // Check for file:// protocol + if hasSource && strings.HasPrefix(source, FileProtocol) { + filePath := strings.TrimPrefix(source, FileProtocol) + name, _ := partMap["name"].(string) + return loadFile(filePath, name, opts) + } + + // Check for url field (already a URL) + if url, ok := partMap["url"].(string); ok { + filename, _ := partMap["filename"].(string) + if filename == "" { + filename, _ = partMap["name"].(string) + } + return &context.ContentPart{ + Type: context.ContentFile, + File: &context.FileAttachment{ + URL: url, + Filename: filename, + }, + }, nil + } + + // Check for file field + if file, ok := partMap["file"].(map[string]interface{}); ok { + url, _ := file["url"].(string) + filename, _ := file["filename"].(string) + return &context.ContentPart{ + Type: context.ContentFile, + File: &context.FileAttachment{ + URL: url, + Filename: filename, + }, + }, nil + } + + return nil, fmt.Errorf("file part requires 'source' (file://...), 'url', or 'file' field") +} + +// parseDataPart parses a data content part +func parseDataPart(partMap map[string]interface{}) (*context.ContentPart, error) { + data, ok := partMap["data"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("data part requires 'data' object") + } + + // Convert to DataContent + dataContent := &context.DataContent{} + + if sources, ok := data["sources"].([]interface{}); ok { + dataContent.Sources = make([]context.DataSource, 0, len(sources)) + for _, src := range sources { + if srcMap, ok := src.(map[string]interface{}); ok { + ds := context.DataSource{} + if t, ok := srcMap["type"].(string); ok { + ds.Type = context.DataSourceType(t) + } + if id, ok := srcMap["id"].(string); ok { + ds.ID = id + } + if name, ok := srcMap["name"].(string); ok { + ds.Name = name + } + if filters, ok := srcMap["filters"].(map[string]interface{}); ok { + ds.Filters = filters + } + if metadata, ok := srcMap["metadata"].(map[string]interface{}); ok { + ds.Metadata = metadata + } + dataContent.Sources = append(dataContent.Sources, ds) + } + } + } + + return &context.ContentPart{ + Type: context.ContentData, + Data: dataContent, + }, nil +} + +// parseGenericPart tries to parse an unknown content part type +func parseGenericPart(partMap map[string]interface{}) (*context.ContentPart, error) { + partType, _ := partMap["type"].(string) + + // Try to create a basic ContentPart + part := &context.ContentPart{ + Type: context.ContentPartType(partType), + } + + // Try to extract text if present + if text, ok := partMap["text"].(string); ok { + part.Text = text + } + + return part, nil +} + +// loadImageFile loads an image file and converts it to a ContentPart +func loadImageFile(filePath string, opts *InputOptions) (*context.ContentPart, error) { + absPath := resolveFilePath(filePath, opts) + + // Read file + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read image file %s: %w", filePath, err) + } + + // Determine MIME type + ext := strings.ToLower(filepath.Ext(absPath)) + mimeType, ok := SupportedImageExtensions[ext] + if !ok { + // Try to detect from extension + mimeType = mime.TypeByExtension(ext) + if mimeType == "" { + mimeType = "application/octet-stream" + } + } + + // Encode to base64 data URL + b64Data := base64.StdEncoding.EncodeToString(data) + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data) + + return &context.ContentPart{ + Type: context.ContentImageURL, + ImageURL: &context.ImageURL{ + URL: dataURL, + Detail: context.DetailAuto, + }, + }, nil +} + +// loadAudioFile loads an audio file and converts it to a ContentPart +func loadAudioFile(filePath string, opts *InputOptions) (*context.ContentPart, error) { + absPath := resolveFilePath(filePath, opts) + + // Read file + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read audio file %s: %w", filePath, err) + } + + // Determine format from extension + ext := strings.ToLower(filepath.Ext(absPath)) + format, ok := SupportedAudioExtensions[ext] + if !ok { + format = strings.TrimPrefix(ext, ".") + } + + // Encode to base64 + b64Data := base64.StdEncoding.EncodeToString(data) + + return &context.ContentPart{ + Type: context.ContentInputAudio, + InputAudio: &context.InputAudio{ + Data: b64Data, + Format: format, + }, + }, nil +} + +// loadFile loads a file and converts it to a ContentPart +func loadFile(filePath string, name string, opts *InputOptions) (*context.ContentPart, error) { + absPath := resolveFilePath(filePath, opts) + + // Read file + data, err := os.ReadFile(absPath) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) + } + + // Determine filename + filename := name + if filename == "" { + filename = filepath.Base(absPath) + } + + // Determine MIME type + ext := strings.ToLower(filepath.Ext(absPath)) + mimeType, ok := SupportedFileExtensions[ext] + if !ok { + mimeType = mime.TypeByExtension(ext) + if mimeType == "" { + mimeType = "application/octet-stream" + } + } + + // Encode to base64 data URL + b64Data := base64.StdEncoding.EncodeToString(data) + dataURL := fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data) + + return &context.ContentPart{ + Type: context.ContentFile, + File: &context.FileAttachment{ + URL: dataURL, + Filename: filename, + }, + }, nil +} + +// resolveFilePath resolves a file path relative to the base directory +// If the path is absolute, it's returned as-is +// If BaseDir is empty, the current working directory is used +func resolveFilePath(filePath string, opts *InputOptions) string { + // If path is absolute, return as-is + if filepath.IsAbs(filePath) { + return filePath + } + + // If BaseDir is set, resolve relative to it + if opts != nil && opts.BaseDir != "" { + return filepath.Join(opts.BaseDir, filePath) + } + + // Otherwise, resolve relative to current working directory + return filePath +} + // parseToolCall converts a map to context.ToolCall func parseToolCall(m map[string]interface{}) (*context.ToolCall, error) { tc := &context.ToolCall{} diff --git a/agent/test/input_test.go b/agent/test/input_test.go new file mode 100644 index 00000000..59d1692f --- /dev/null +++ b/agent/test/input_test.go @@ -0,0 +1,487 @@ +package test + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/yaoapp/yao/agent/context" +) + +func TestParseInput_String(t *testing.T) { + input := "Hello world" + messages, err := ParseInput(input) + if err != nil { + t.Fatalf("ParseInput failed: %v", err) + } + + if len(messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(messages)) + } + + if messages[0].Role != context.RoleUser { + t.Errorf("Expected role 'user', got '%s'", messages[0].Role) + } + + content, ok := messages[0].Content.(string) + if !ok { + t.Fatalf("Expected string content, got %T", messages[0].Content) + } + if content != "Hello world" { + t.Errorf("Expected content 'Hello world', got '%s'", content) + } +} + +func TestParseInput_MessageMap(t *testing.T) { + input := map[string]interface{}{ + "role": "user", + "content": "Test message", + } + + messages, err := ParseInput(input) + if err != nil { + t.Fatalf("ParseInput failed: %v", err) + } + + if len(messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(messages)) + } + + if messages[0].Role != context.RoleUser { + t.Errorf("Expected role 'user', got '%s'", messages[0].Role) + } +} + +func TestParseInput_MessageArray(t *testing.T) { + input := []interface{}{ + map[string]interface{}{"role": "user", "content": "Hello"}, + map[string]interface{}{"role": "assistant", "content": "Hi there"}, + map[string]interface{}{"role": "user", "content": "Follow-up"}, + } + + messages, err := ParseInput(input) + if err != nil { + t.Fatalf("ParseInput failed: %v", err) + } + + if len(messages) != 3 { + t.Fatalf("Expected 3 messages, got %d", len(messages)) + } + + if messages[0].Role != context.RoleUser { + t.Errorf("Expected first message role 'user', got '%s'", messages[0].Role) + } + if messages[1].Role != context.RoleAssistant { + t.Errorf("Expected second message role 'assistant', got '%s'", messages[1].Role) + } +} + +func TestParseInput_ContentParts(t *testing.T) { + input := map[string]interface{}{ + "role": "user", + "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "Analyze this"}, + map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{ + "url": "https://example.com/image.jpg", + "detail": "high", + }}, + }, + } + + messages, err := ParseInput(input) + if err != nil { + t.Fatalf("ParseInput failed: %v", err) + } + + if len(messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(messages)) + } + + parts, ok := messages[0].Content.([]context.ContentPart) + if !ok { + t.Fatalf("Expected []ContentPart, got %T", messages[0].Content) + } + + if len(parts) != 2 { + t.Fatalf("Expected 2 content parts, got %d", len(parts)) + } + + if parts[0].Type != context.ContentText { + t.Errorf("Expected first part type 'text', got '%s'", parts[0].Type) + } + if parts[0].Text != "Analyze this" { + t.Errorf("Expected text 'Analyze this', got '%s'", parts[0].Text) + } + + if parts[1].Type != context.ContentImageURL { + t.Errorf("Expected second part type 'image_url', got '%s'", parts[1].Type) + } + if parts[1].ImageURL == nil { + t.Fatal("Expected ImageURL to be set") + } + if parts[1].ImageURL.URL != "https://example.com/image.jpg" { + t.Errorf("Expected URL 'https://example.com/image.jpg', got '%s'", parts[1].ImageURL.URL) + } + if parts[1].ImageURL.Detail != context.DetailHigh { + t.Errorf("Expected detail 'high', got '%s'", parts[1].ImageURL.Detail) + } +} + +func TestParseInputWithOptions_FileProtocol_Image(t *testing.T) { + // Create a temporary test image file + tmpDir := t.TempDir() + imgPath := filepath.Join(tmpDir, "test.png") + + // Create a minimal PNG file (1x1 pixel, red) + pngData := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, // IDAT chunk + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE, + 0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, // IEND chunk + 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + } + if err := os.WriteFile(imgPath, pngData, 0644); err != nil { + t.Fatalf("Failed to create test image: %v", err) + } + + input := map[string]interface{}{ + "role": "user", + "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "Analyze this image"}, + map[string]interface{}{"type": "image", "source": "file://test.png"}, + }, + } + + opts := &InputOptions{BaseDir: tmpDir} + messages, err := ParseInputWithOptions(input, opts) + if err != nil { + t.Fatalf("ParseInputWithOptions failed: %v", err) + } + + if len(messages) != 1 { + t.Fatalf("Expected 1 message, got %d", len(messages)) + } + + parts, ok := messages[0].Content.([]context.ContentPart) + if !ok { + t.Fatalf("Expected []ContentPart, got %T", messages[0].Content) + } + + if len(parts) != 2 { + t.Fatalf("Expected 2 content parts, got %d", len(parts)) + } + + // Check image part + imgPart := parts[1] + if imgPart.Type != context.ContentImageURL { + t.Errorf("Expected type 'image_url', got '%s'", imgPart.Type) + } + if imgPart.ImageURL == nil { + t.Fatal("Expected ImageURL to be set") + } + if !strings.HasPrefix(imgPart.ImageURL.URL, "data:image/png;base64,") { + t.Errorf("Expected base64 data URL, got '%s'", imgPart.ImageURL.URL[:50]) + } + + // Verify the base64 content + b64Part := strings.TrimPrefix(imgPart.ImageURL.URL, "data:image/png;base64,") + decoded, err := base64.StdEncoding.DecodeString(b64Part) + if err != nil { + t.Fatalf("Failed to decode base64: %v", err) + } + if len(decoded) != len(pngData) { + t.Errorf("Decoded data length mismatch: expected %d, got %d", len(pngData), len(decoded)) + } +} + +func TestParseInputWithOptions_FileProtocol_Audio(t *testing.T) { + // Create a temporary test audio file + tmpDir := t.TempDir() + audioPath := filepath.Join(tmpDir, "test.wav") + + // Create a minimal WAV file header + wavData := []byte{ + 0x52, 0x49, 0x46, 0x46, // "RIFF" + 0x24, 0x00, 0x00, 0x00, // File size - 8 + 0x57, 0x41, 0x56, 0x45, // "WAVE" + 0x66, 0x6D, 0x74, 0x20, // "fmt " + 0x10, 0x00, 0x00, 0x00, // Subchunk1Size (16 for PCM) + 0x01, 0x00, // AudioFormat (1 = PCM) + 0x01, 0x00, // NumChannels (1 = mono) + 0x44, 0xAC, 0x00, 0x00, // SampleRate (44100) + 0x88, 0x58, 0x01, 0x00, // ByteRate + 0x02, 0x00, // BlockAlign + 0x10, 0x00, // BitsPerSample (16) + 0x64, 0x61, 0x74, 0x61, // "data" + 0x00, 0x00, 0x00, 0x00, // Subchunk2Size (0 = no data) + } + if err := os.WriteFile(audioPath, wavData, 0644); err != nil { + t.Fatalf("Failed to create test audio: %v", err) + } + + input := map[string]interface{}{ + "role": "user", + "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "Transcribe this"}, + map[string]interface{}{"type": "audio", "source": "file://test.wav"}, + }, + } + + opts := &InputOptions{BaseDir: tmpDir} + messages, err := ParseInputWithOptions(input, opts) + if err != nil { + t.Fatalf("ParseInputWithOptions failed: %v", err) + } + + parts, ok := messages[0].Content.([]context.ContentPart) + if !ok { + t.Fatalf("Expected []ContentPart, got %T", messages[0].Content) + } + + // Check audio part + audioPart := parts[1] + if audioPart.Type != context.ContentInputAudio { + t.Errorf("Expected type 'input_audio', got '%s'", audioPart.Type) + } + if audioPart.InputAudio == nil { + t.Fatal("Expected InputAudio to be set") + } + if audioPart.InputAudio.Format != "wav" { + t.Errorf("Expected format 'wav', got '%s'", audioPart.InputAudio.Format) + } + if audioPart.InputAudio.Data == "" { + t.Error("Expected base64 data to be set") + } +} + +func TestParseInputWithOptions_FileProtocol_File(t *testing.T) { + // Create a temporary test file + tmpDir := t.TempDir() + pdfPath := filepath.Join(tmpDir, "document.pdf") + + // Create a minimal PDF file + pdfData := []byte("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF") + if err := os.WriteFile(pdfPath, pdfData, 0644); err != nil { + t.Fatalf("Failed to create test PDF: %v", err) + } + + input := map[string]interface{}{ + "role": "user", + "content": []interface{}{ + map[string]interface{}{"type": "text", "text": "Analyze this document"}, + map[string]interface{}{"type": "file", "source": "file://document.pdf", "name": "my_doc.pdf"}, + }, + } + + opts := &InputOptions{BaseDir: tmpDir} + messages, err := ParseInputWithOptions(input, opts) + if err != nil { + t.Fatalf("ParseInputWithOptions failed: %v", err) + } + + parts, ok := messages[0].Content.([]context.ContentPart) + if !ok { + t.Fatalf("Expected []ContentPart, got %T", messages[0].Content) + } + + // Check file part + filePart := parts[1] + if filePart.Type != context.ContentFile { + t.Errorf("Expected type 'file', got '%s'", filePart.Type) + } + if filePart.File == nil { + t.Fatal("Expected File to be set") + } + if filePart.File.Filename != "my_doc.pdf" { + t.Errorf("Expected filename 'my_doc.pdf', got '%s'", filePart.File.Filename) + } + if !strings.HasPrefix(filePart.File.URL, "data:application/pdf;base64,") { + t.Errorf("Expected base64 data URL with PDF mime type, got '%s'", filePart.File.URL[:40]) + } +} + +func TestParseInputWithOptions_FileProtocol_AbsolutePath(t *testing.T) { + // Create a temporary test image file + tmpDir := t.TempDir() + imgPath := filepath.Join(tmpDir, "absolute.png") + + // Create a minimal PNG file + pngData := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, + 0x00, 0x00, 0x03, 0x00, 0x01, 0x00, 0x05, 0xFE, + 0xD4, 0xEF, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, + 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, + } + if err := os.WriteFile(imgPath, pngData, 0644); err != nil { + t.Fatalf("Failed to create test image: %v", err) + } + + // Use absolute path + input := map[string]interface{}{ + "role": "user", + "content": []interface{}{ + map[string]interface{}{"type": "image", "source": "file://" + imgPath}, + }, + } + + // BaseDir should be ignored for absolute paths + opts := &InputOptions{BaseDir: "/some/other/dir"} + messages, err := ParseInputWithOptions(input, opts) + if err != nil { + t.Fatalf("ParseInputWithOptions failed: %v", err) + } + + parts, ok := messages[0].Content.([]context.ContentPart) + if !ok { + t.Fatalf("Expected []ContentPart, got %T", messages[0].Content) + } + + if parts[0].Type != context.ContentImageURL { + t.Errorf("Expected type 'image_url', got '%s'", parts[0].Type) + } +} + +func TestParseInputWithOptions_FileNotFound(t *testing.T) { + input := map[string]interface{}{ + "role": "user", + "content": []interface{}{ + map[string]interface{}{"type": "image", "source": "file://nonexistent.png"}, + }, + } + + opts := &InputOptions{BaseDir: t.TempDir()} + _, err := ParseInputWithOptions(input, opts) + if err == nil { + t.Fatal("Expected error for non-existent file") + } + if !strings.Contains(err.Error(), "failed to read image file") { + t.Errorf("Expected 'failed to read image file' error, got: %v", err) + } +} + +func TestResolveFilePath(t *testing.T) { + tests := []struct { + name string + filePath string + baseDir string + expected string + }{ + { + name: "relative path with base dir", + filePath: "fixtures/image.png", + baseDir: "/app/tests", + expected: "/app/tests/fixtures/image.png", + }, + { + name: "relative path without base dir", + filePath: "fixtures/image.png", + baseDir: "", + expected: "fixtures/image.png", + }, + { + name: "absolute path ignores base dir", + filePath: "/absolute/path/image.png", + baseDir: "/app/tests", + expected: "/absolute/path/image.png", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &InputOptions{BaseDir: tt.baseDir} + result := resolveFilePath(tt.filePath, opts) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestExtractTextContent(t *testing.T) { + tests := []struct { + name string + content interface{} + expected string + }{ + { + name: "string content", + content: "Hello world", + expected: "Hello world", + }, + { + name: "content parts array", + content: []interface{}{ + map[string]interface{}{"type": "text", "text": "First"}, + map[string]interface{}{"type": "image", "source": "file://test.png"}, + map[string]interface{}{"type": "text", "text": "Second"}, + }, + expected: "First\nSecond", + }, + { + name: "nil content", + content: nil, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExtractTextContent(tt.content) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestSummarizeInput(t *testing.T) { + tests := []struct { + name string + input interface{} + maxLen int + expected string + }{ + { + name: "short string", + input: "Hello", + maxLen: 10, + expected: "Hello", + }, + { + name: "long string truncated", + input: "This is a very long message that should be truncated", + maxLen: 20, + expected: "This is a very lo...", + }, + { + name: "message array - last user message", + input: []interface{}{ + map[string]interface{}{"role": "user", "content": "First"}, + map[string]interface{}{"role": "assistant", "content": "Response"}, + map[string]interface{}{"role": "user", "content": "Last user message"}, + }, + maxLen: 50, + expected: "Last user message", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SummarizeInput(tt.input, tt.maxLen) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} diff --git a/agent/test/loader.go b/agent/test/loader.go index c648a617..49de1a77 100644 --- a/agent/test/loader.go +++ b/agent/test/loader.go @@ -25,8 +25,12 @@ func (l *JSONLLoader) Load() ([]*Case, error) { } // LoadFile loads test cases from a JSONL file +// If path is relative and YAO_ROOT is set, resolves relative to YAO_ROOT func (l *JSONLLoader) LoadFile(path string) ([]*Case, error) { - file, err := os.Open(path) + // Resolve path relative to YAO_ROOT if it's a relative path + resolvedPath := ResolvePathWithYaoRoot(path) + + file, err := os.Open(resolvedPath) if err != nil { return nil, fmt.Errorf("failed to open file %s: %w", path, err) } diff --git a/agent/test/resolver.go b/agent/test/resolver.go index 4364b9f2..38b2f61e 100644 --- a/agent/test/resolver.go +++ b/agent/test/resolver.go @@ -55,6 +55,7 @@ func (r *PathResolver) ResolveFromCwd() (*AgentInfo, error) { // ResolveFromPath resolves the agent by traversing up from the input file path // It looks for package.yao in parent directories +// If YAO_ROOT is set, it also considers paths relative to YAO_ROOT func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) { // Get absolute path absPath, err := filepath.Abs(inputPath) @@ -82,6 +83,37 @@ func (r *PathResolver) ResolveFromPath(inputPath string) (*AgentInfo, error) { dir = parent } + // If YAO_ROOT is set, try resolving relative to it + yaoRoot := os.Getenv("YAO_ROOT") + if yaoRoot != "" { + // Try the input path relative to YAO_ROOT + relPath := inputPath + // If inputPath is absolute, try to make it relative + if filepath.IsAbs(inputPath) { + // Check if inputPath is under YAO_ROOT + if rel, err := filepath.Rel(yaoRoot, inputPath); err == nil && !strings.HasPrefix(rel, "..") { + relPath = rel + } + } + + // Traverse up from YAO_ROOT + relPath + dir = filepath.Join(yaoRoot, filepath.Dir(relPath)) + for { + packagePath := filepath.Join(dir, "package.yao") + if _, err := os.Stat(packagePath); err == nil { + // Found package.yao + return r.loadAgentFromPath(dir, packagePath) + } + + // Move to parent directory, but don't go above YAO_ROOT + parent := filepath.Dir(dir) + if parent == dir || !strings.HasPrefix(parent, yaoRoot) { + break + } + dir = parent + } + } + return nil, fmt.Errorf("no package.yao found in path hierarchy of %s", inputPath) } @@ -180,7 +212,8 @@ func ValidateOptions(opts *Options) error { // For file mode, check input file exists if opts.InputMode == InputModeFile { - if _, err := os.Stat(opts.Input); os.IsNotExist(err) { + resolvedPath := ResolvePathWithYaoRoot(opts.Input) + if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { return fmt.Errorf("input file not found: %s", opts.Input) } } @@ -297,7 +330,9 @@ func MergeOptions(opts *Options, defaults *Options) *Options { // Format: {input_directory}/output-{timestamp}.jsonl // Timestamp format: YYYYMMDDHHMMSS func GenerateDefaultOutputPath(inputPath string) string { - dir := filepath.Dir(inputPath) + // Resolve input path considering YAO_ROOT + resolvedPath := ResolvePathWithYaoRoot(inputPath) + dir := filepath.Dir(resolvedPath) timestamp := time.Now().Format("20060102150405") filename := fmt.Sprintf("output-%s.jsonl", timestamp) return filepath.Join(dir, filename) @@ -328,3 +363,19 @@ func CreateTestCaseFromMessage(message string) *Case { Input: message, } } + +// ResolvePathWithYaoRoot resolves a file path relative to current directory +// No fallback to YAO_ROOT - paths are always resolved from current working directory +func ResolvePathWithYaoRoot(path string) string { + // If path is absolute, return as-is + if filepath.IsAbs(path) { + return path + } + + // Resolve relative to current directory + absPath, err := filepath.Abs(path) + if err != nil { + return path + } + return absPath +} diff --git a/agent/test/runner.go b/agent/test/runner.go index 2394e1a4..32658fdb 100644 --- a/agent/test/runner.go +++ b/agent/test/runner.go @@ -5,6 +5,7 @@ import ( stdContext "context" "fmt" "os" + "path/filepath" "sync" "time" @@ -105,8 +106,9 @@ func (r *Executor) RunDirect() (*Report, error) { defer cancel() ctx.Context = timeoutCtx - // Parse input to messages - messages, err := tc.GetMessages() + // Parse input to messages with file loading support + inputOpts := r.getInputOptions() + messages, err := tc.GetMessagesWithOptions(inputOpts) if err != nil { return nil, fmt.Errorf("failed to parse input: %w", err) } @@ -320,8 +322,10 @@ func (r *Executor) runSingleTest(ast *assistant.Assistant, tc *Case, agentID str Options: tc.Options, } - // Parse input to messages - messages, err := tc.GetMessages() + // Parse input to messages with file loading support + // BaseDir is derived from the input file directory + inputOpts := r.getInputOptions() + messages, err := tc.GetMessagesWithOptions(inputOpts) if err != nil { result.Status = StatusError result.Error = fmt.Sprintf("failed to parse input: %s", err.Error()) @@ -613,3 +617,19 @@ func validateOutput(actual, expected interface{}) bool { return string(actualJSON) == string(expectedJSON) } + +// getInputOptions returns InputOptions based on the runner configuration +// BaseDir is derived from the input file directory (for file mode) or current working directory +func (r *Executor) getInputOptions() *InputOptions { + opts := &InputOptions{} + + // For file mode, use the input file's directory as base + if r.opts.InputMode == InputModeFile && r.opts.Input != "" { + // Resolve path considering YAO_ROOT + resolvedPath := ResolvePathWithYaoRoot(r.opts.Input) + opts.BaseDir = filepath.Dir(resolvedPath) + } + // For message mode, BaseDir remains empty (uses current working directory) + + return opts +} diff --git a/agent/test/types.go b/agent/test/types.go index 5b29bc53..b40ab457 100644 --- a/agent/test/types.go +++ b/agent/test/types.go @@ -492,6 +492,13 @@ func (tc *Case) GetMessages() ([]context.Message, error) { return ParseInput(tc.Input) } +// GetMessagesWithOptions converts the Input to a slice of context.Message with options +// This handles all input formats: string, Message, []Message +// It also processes file:// references in content parts +func (tc *Case) GetMessagesWithOptions(opts *InputOptions) ([]context.Message, error) { + return ParseInputWithOptions(tc.Input, opts) +} + // GetTimeout returns the timeout duration for this test case // Returns the override timeout if set, otherwise returns the default func (tc *Case) GetTimeout(defaultTimeout time.Duration) time.Duration { diff --git a/bin/yao-dev b/bin/yao-dev new file mode 100755 index 00000000..3b1d70e3 --- /dev/null +++ b/bin/yao-dev @@ -0,0 +1,61 @@ +#!/bin/bash + +# yao-dev - Run yao from source for real-time debugging +# Uses go -C to compile from source while keeping current directory as app root + +# Get the real path of this script (resolve symlinks) +SCRIPT_PATH="${BASH_SOURCE[0]}" +while [ -L "$SCRIPT_PATH" ]; do + SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + SCRIPT_PATH="$(readlink "$SCRIPT_PATH")" + # If the link is relative, resolve it relative to the directory + [[ "$SCRIPT_PATH" != /* ]] && SCRIPT_PATH="$SCRIPT_DIR/$SCRIPT_PATH" +done +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" + +# YAO source directory is the parent of bin/ +YAO_SOURCE_DIR="$(dirname "$SCRIPT_DIR")" + +if [ ! -d "$YAO_SOURCE_DIR" ]; then + echo "Error: yao source directory not found at $YAO_SOURCE_DIR" + exit 1 +fi + +if [ ! -f "$YAO_SOURCE_DIR/go.mod" ]; then + echo "Error: go.mod not found in $YAO_SOURCE_DIR, not a valid yao source directory" + exit 1 +fi + +# Find app root by looking for app.yao in current directory or parent directories +find_app_root() { + local dir="$(pwd)" + while [ "$dir" != "/" ]; do + if [ -f "$dir/app.yao" ] || [ -f "$dir/app.json" ] || [ -f "$dir/app.jsonc" ]; then + echo "$dir" + return 0 + fi + dir="$(dirname "$dir")" + done + # If not found, use current directory as fallback + pwd +} + +# Set YAO_ROOT to app root directory +export YAO_ROOT="$(find_app_root)" + +# Convert relative file paths in arguments to absolute paths +# This is needed because go -C changes the working directory +ARGS=() +for arg in "$@"; do + # Check if it looks like a relative path + if [[ "$arg" == ./* ]] || [[ "$arg" == ../* ]]; then + # Always convert to absolute path based on current directory + ARGS+=("$(pwd)/${arg#./}") + else + ARGS+=("$arg") + fi +done + +# Use go -C to run from source directory while staying in current directory +exec go -C "$YAO_SOURCE_DIR" run . "${ARGS[@]}" + diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go index 4db2be45..b7eb2549 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -52,6 +52,7 @@ func L(words string) string { // Boot sets the configuration func Boot() { + // Use root from Init() unless appPath is explicitly specified root := config.Conf.Root if appPath != "" { r, err := filepath.Abs(appPath) @@ -60,15 +61,13 @@ func Boot() { } root = r } + + // Load .env file, preserving the correct root if envFile != "" { - config.Conf = config.LoadFrom(envFile) + config.Conf = config.LoadFromWithRoot(envFile, root) } else { - config.Conf = config.LoadFrom(filepath.Join(root, ".env")) + config.Conf = config.LoadFromWithRoot(filepath.Join(root, ".env"), root) } - if config.Conf.Mode == "production" { - config.Production() - } else if config.Conf.Mode == "development" { - config.Development() - } + config.ApplyMode() } diff --git a/config/config.go b/config/config.go index d7239494..1d932fbf 100644 --- a/config/config.go +++ b/config/config.go @@ -32,52 +32,106 @@ func init() { // Init setting func Init() { + // Determine app root: YAO_ROOT env > find app.yao > current directory + root := os.Getenv("YAO_ROOT") + if root == "" { + root = findAppRoot() + } + if root == "" { + root = "." + } - filename, _ := filepath.Abs(filepath.Join(".", ".env")) + filename := filepath.Join(root, ".env") if _, err := os.Stat(filename); errors.Is(err, os.ErrNotExist) { - Conf = Load() - if Conf.Mode == "production" { - Production() - } else if Conf.Mode == "development" { - Development() - } + Conf = LoadWithRoot(root) + ApplyMode() return } - Conf = LoadFrom(filename) - if Conf.Mode == "production" { + // Load .env then override root if auto-detected + Conf = LoadFromWithRoot(filename, root) + ApplyMode() +} + +// ApplyMode applies production or development mode based on Conf.Mode +func ApplyMode() { + switch Conf.Mode { + case "production": Production() - } else if Conf.Mode == "development" { + case "development": Development() } } +// findAppRoot finds the Yao application root directory by looking for app.yao +// It traverses up from the current directory until it finds app.yao or reaches the filesystem root +func findAppRoot() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + + for { + // Check for app.yao, app.json, or app.jsonc + for _, appFile := range []string{"app.yao", "app.json", "app.jsonc"} { + appFilePath := filepath.Join(dir, appFile) + if _, err := os.Stat(appFilePath); err == nil { + return dir + } + } + + // Move to parent directory + parent := filepath.Dir(dir) + if parent == dir { + // Reached root, no app.yao found + break + } + dir = parent + } + + return "" +} + // LoadFrom 从配置项中加载 func LoadFrom(envfile string) Config { + return LoadFromWithRoot(envfile, "") +} +// LoadFromWithRoot loads config from env file with optional root override +func LoadFromWithRoot(envfile string, root string) Config { file, err := filepath.Abs(envfile) if err != nil { - cfg := Load() + cfg := LoadWithRoot(root) ReloadLog() return cfg } // load from env godotenv.Overload(file) - cfg := Load() + cfg := LoadWithRoot(root) ReloadLog() return cfg } // Load the config func Load() Config { + return LoadWithRoot("") +} + +// LoadWithRoot loads config with an optional root override +// If root is empty, uses YAO_ROOT env or current directory +func LoadWithRoot(root string) Config { cfg := Config{} if err := env.Parse(&cfg); err != nil { exception.New("Can't read config %s", 500, err.Error()).Throw() } - // Root path - cfg.Root, _ = filepath.Abs(cfg.Root) + // Root path: use provided root > env YAO_ROOT > default "." + if root != "" { + cfg.Root, _ = filepath.Abs(root) + } else { + cfg.Root, _ = filepath.Abs(cfg.Root) + } // App Root if cfg.AppSource == "" {